Commit Graph

472 Commits

Author SHA1 Message Date
Anso d4fa4a4965 fix(host-console): audit session lifecycle and harden path, resize, and route gating (#1263)
* fix(host-console): audit session lifecycle and harden path, resize, and route gating

Record an audit-log entry when a host console session opens and when it
closes (capturing user, node, client IP, and timestamp), so interactive
host-shell access leaves a durable, accountable trail instead of only an
ephemeral log line.

Fix a stack-path boundary check that allowed a sibling directory sharing
the base path's prefix to pass; directory resolution now uses the
canonical within-base check via a small testable helper.

Validate terminal resize frames (positive integers within a sane bound)
before forwarding them to the PTY, dropping malformed frames instead of
passing them through.

Mirror the backend admin-only console permission on the frontend route so
a non-admin who reaches the view cannot mount a console the server would
reject, and log (rather than silently swallow) a working-directory
resolution failure.

Add unit coverage for the path helper, audit open/close rows, resize
validation, and the spawn-error path, plus a WebSocket-upgrade integration
test exercising the full gate chain and a live session-open audit row.

* fix(host-console): record the node the shell actually runs in

When the requested node's directory cannot be resolved, the session falls
back to the default node's base directory. Record that fallback node in the
session audit row (and log it) so the audit trail names the node the shell
actually runs in rather than the originally requested one. Strengthen the
upgrade integration test to assert the open row captures the user, node, and
client IP.
2026-05-31 20:29:30 -04:00
Anso d03d97d964 fix(nodes): close capability-gating gaps in node compatibility (#1261)
* fix(nodes): close capability-gating gaps in node compatibility

Vulnerability scanning is now gated correctly on whether the active node
advertises support for it:

- A node without the Trivy binary stops advertising the scanning capability.
  Previously the capability was toggled only on a state change, so a node that
  booted without Trivy kept advertising scanning it could not perform.
- The control node's own capability list now reflects features disabled at
  runtime, matching what it advertises to peers.
- The scan history surface shows a clear "not available on this node" card,
  with its header actions hidden, instead of attempting a request that fails.

A node's version and capability metadata now refreshes immediately after a
connection test or a completed update, rather than waiting out the cache.

Capability gates fail closed to the unavailable card when a node's metadata
request errors, instead of staying open until the next fetch.

Adds a test that fails if the frontend and backend capability lists drift,
plus coverage for the metadata error path, the runtime-disabled local meta,
the scanning capability sync, and the metadata cache invalidation paths.

* fix(nodes): refresh node metadata client-side after a connection test

A connection test dropped the server-side metadata cache, but the dashboard
kept its own cached copy until the client TTL expired, so version and
capability gates could stay stale in the browser. The test now forces a
client-side metadata refresh for that node, so the version pill and gates
reflect the node's current state immediately.

Also strips any URL userinfo before logging the metadata fetch target, and
makes the scanning-capability detection test deterministically exercise the
no-binary disable path rather than depending on whether the runner has Trivy.
2026-05-31 20:28:18 -04:00
Anso 6fc7f200a6 fix(scheduled-ops): run stack lifecycle schedules on remote nodes and harden run visibility (#1260)
* fix(scheduled-ops): run stack lifecycle schedules on remote nodes and harden run visibility

Stack lifecycle schedules (Restart, Stop, Take Down, Start, Backup Stack
Files) now run against whichever node the schedule targets, local or
remote. Each remote run proxies to that node's own stack-operation
endpoint, so a hub-managed schedule reaches the node that actually holds
the stack. Restart with a service subset restarts each selected service
and, if one fails, names the services already restarted so run history
reflects the stack's partial state. Auto-start on a remote node runs that
node's own pre-deploy scan-policy check against the images it holds.

Add POST /api/stacks/:name/backup to trigger an on-demand backup of a
stack's compose and env files (the same rollback snapshot a deploy
takes); it backs the remote backup schedule and is available to operators
on its own.

A scheduled task that reaches execution on an unpaid licence is now
skipped and written to run history as a failed run, so a manual trigger
that returned a queued response never silently disappears.

Test plan:
- Backend unit + integration: scheduler-service (remote proxy per action,
  per-service fan-out, auto-start policy delegation, remote-failure and
  no-credentials paths, unpaid-tier skip), stack-backup-route
  (auth/role/paid/404/400/500), scheduled-tasks-routes.
- Frontend component test for the schedules view (list, prefill, node
  filter, create payload).
- tsc and lint clean on both packages.

* fix(scheduled-ops): lock the stack-files backup route against concurrent stack ops

The stack-files backup writes the same slot the pre-deploy rollback
snapshot uses, so running it while a deploy, update, or rollback is in
flight on the same stack could overwrite the rollback point. The backup
route now takes the per-stack operation lock (as deploy/down/restart do)
and returns 409 when the stack is busy, keeping the rollback snapshot
intact. Adds the 'backup' action to the stack-op lock type and a busy
participle for the 409 message.

* fix(scheduled-ops): enforce backup-path containment inline at the filesystem sink

The on-demand backup route passes the stack name straight into
backupStackFiles, so resolve the backup directory against the backup root
and confirm containment with an inline startsWith check before the
mkdir/copy/write sinks, matching the barrier restoreStackFiles already
uses. The stack name is validated at the route and again by
resolveStackDir, so this is defense in depth that also closes a
static path-injection finding on the new call path.
2026-05-31 17:47:34 -04:00
Anso 5e66b54153 fix(audit-log): neutralize CSV export injection, clamp pagination, bound anomaly history (#1259)
* fix(audit-log): neutralize CSV export injection, clamp pagination, bound anomaly history

Harden the Admiral audit log without changing its tier or hub-only gating.

- CSV export now defuses formula injection: any field that a spreadsheet
  would evaluate as a formula (leading = + - @, or a trigger behind leading
  whitespace, or a leading tab/CR) is prefixed with a single quote before
  RFC 4180 quoting. Audit summaries embed user-controlled resource names, so
  this closes a path where a crafted name could execute on export open.
- Clamp page and limit to positive bounds on the list endpoint so a negative
  limit can no longer reach SQLite as "unlimited" and dump the whole table.
- Bound the anomaly and stats history reads to a capped slice of recent rows
  so the analysis paths stay within fixed memory and latency on large
  histories instead of scanning the full retention window per request.
- Surface failed audit list and stats fetches through the standard error
  toast instead of leaving the view silently stale.
- Add a developer-mode-gated diagnostic log to the stats endpoint for parity
  with the list and export handlers.

Covered by new unit and HTTP-integration tests (CSV neutralization through
the real export route, pagination clamps, bounded history, stats endpoint,
anomaly annotation) and verified end to end in the browser.

* fix(audit-log): compute signal-rail stats with exact SQL aggregates

Address review feedback on the earlier history-cap change. The cap was
correct for the anomaly baseline but made the stats tiles (events, actors,
failure rate, hourly series) silently undercount on a hub with more than the
cap's worth of rows in the window, since they were derived from the capped
row slice.

- Add DatabaseService.getAuditStatsInputs: exact counts via SQL COUNT /
  COUNT(DISTINCT) / GROUP BY hour, and new-ip detection over the small
  DISTINCT (user, ip) pair sets. No row cap, so the tiles stay exact at any
  window size while memory stays bounded.
- Reduce computeAuditStats to a pure formatter over those aggregates.
- Keep the bounded history read only for the list endpoint's anomaly
  annotation, where a recent-activity baseline is an acceptable heuristic.
- Skip the redundant load-failure toast when a fetch fails with a handled
  401, so an expired session does not stack toasts on top of logout.

Adds exactness tests for the aggregate counts, distinct-actor handling, and
new-ip detection, and strengthens the pagination-clamp tests.

* fix(audit-log): exclude future-dated rows and make the new-ip sample deterministic

Two small parity fixes on the stats aggregates: upper-bound every current
window by `now` so a future-dated row (clock skew or a fixture) cannot inflate
the live counts, and order the new-ip pair scan so the sample actor shown in
the tile detail is stable. Adds a test asserting a future row is excluded.
2026-05-31 16:36:07 -04:00
Anso 69edb0dcbb fix(observability): gate global logs to admins, scope to managed containers, harden SSE (#1254)
* fix(observability): gate global logs to admins, scope to managed containers, harden SSE

Make the Logs feed an administrator view enforced on both sides (requireAdmin on
the /api/logs/global poll and SSE routes; the Logs nav item plus a redirect guard
on the frontend), and scope the feed to Sencho-managed containers only via a
shared isManagedByComposeDir helper that /stats now reuses.

Harden the SSE stream: a stateful frame demuxer that survives chunk boundaries so
a Docker frame split across reads is reassembled instead of dropped or garbled; a
per-stream error listener so one broken follow stream cannot crash the event loop
(it posts a single degraded notice and keeps the others alive); a cap on
concurrent follow streams with a truncation notice; a bounded initial tail; and
backpressure that pauses the source streams when the client is slow and resumes on
drain. Bound the polling snapshot's per-container fan-out with a concurrency limit.

Add process-local, in-memory log-stream counters exposed at the admin-only
/api/system/log-stream-metrics endpoint (active connections, lines streamed,
attach and frame errors). Collapse the view to the local hub and remove the dead
remote-node handling.

* fix(observability): close remote-proxy bypass of the global-logs admin gate

The logs feed's requireAdmin lives in the local route handler, which the remote
proxy skips when forwarding a request whose nodeId targets a remote node. A hub
user could therefore request /api/logs/global*, /api/logs/global/stream, or
/api/system/log-stream-metrics with x-node-id (or ?nodeId= for the SSE transport)
pointing at a remote node and have it served as the node-proxy admin on the far
side, sidestepping the gate entirely.

Add these paths to HUB_ONLY_PREFIXES so hubOnlyGuard rejects a remote nodeId with
403 before the proxy runs, matching the existing protection on audit-log,
scheduled-tasks, and notification-routes. Add regression tests covering the
collection path, the SSE sub-path (both the x-node-id header and the ?nodeId=
query transport), and the stream-metrics endpoint.
2026-05-29 21:09:20 -04:00
Anso a5bfd48005 fix(global-search): close the command palette on Escape deterministically (#1256)
When the global command palette was opened with Ctrl+K, pressing Escape
sometimes failed to close it. While the palette is open the cross-node
stack search streams results in and re-renders the list; an Escape that
landed during that re-render churn was intermittently dropped before the
dialog dismissed, leaving the palette stuck open.

The palette now handles Escape on its search input and closes itself,
instead of depending only on the dialog's built-in dismissal. The close
is idempotent, so nothing changes when the dialog already dismisses on
the same key. Adds a unit test covering the Escape close.
2026-05-29 21:06:23 -04:00
Anso 98049e3b1c fix(global-search): surface unreachable nodes and harden the command palette (#1253)
* fix(global-search): surface unreachable nodes and harden the command palette

The command palette discarded the cross-node search hook's failedNodes, so a search run while a fleet node was down silently returned partial results with no sign a host was skipped. It now renders an "N nodes unreachable" line and still shows the stacks it could gather.

The shared cross-node search hook fetched every node's stack list and statuses on every keystroke. It now fans out once per search session and filters the cached inventory client-side as the query is refined, cutting per-keystroke fleet traffic. A 200 response with an unparseable status body degrades stacks to unknown instead of failing the whole node.

The palette now owns result matching (cmdk's built-in fuzzy filter is disabled), so Pages, Nodes, and Stacks match by case-insensitive substring in a deterministic order and the 50-row cap applies to the real match set rather than a re-sorted slice.

Adds unit coverage for the hook and palette, plus a Playwright journey spec.

* fix(global-search): clear stale cross-node results when the active node changes

When excludeNodeId changed mid-search (the sidebar switches it on active-node change), the hook started a fresh fanout but left the previous session's inventory and failedNodes visible until the refetch resolved, so the newly active node could briefly appear under the other-nodes results or a stale unreachable warning could persist. The new session now drops prior results synchronously before refetching.
2026-05-29 19:04:39 -04:00
Anso d41282e352 fix(blueprints): gate Federation pin control on admin role (#1252)
* fix(blueprints): gate Federation pin control on admin role

The Federation tab rendered an editable pin control to any Admiral-tier user, but
PUT /api/blueprints/:id/pin requires admin role, so a non-admin Admiral user saw a
dropdown that returned 403 on use. Thread the admin flag into FederationTab and render
the pin placement read-only (with an administrator-required hint) for non-admins,
matching the existing canEdit pattern in the Deployments tab. The backend guard already
enforced admin; this aligns the UI affordance with it.

Add backend coverage for the tier/role authorization matrix across the blueprint routes,
remote-node deploy/withdraw ordering and failure mapping, edge cases (disable-with-active
409, selector cap, marker drift, cross-blueprint withdraw refusal), service developer-mode
diagnostics, and a frontend render-gate test for both admin and non-admin states.

* fix(blueprints): gate Apply action on admin role in blueprint detail

The blueprint detail sheet rendered an enabled "Apply now" control to any paid user,
but POST /api/blueprints/:id/apply requires admin. Gate the primary action on canEdit
so it matches the already-gated Edit / Disable / Delete actions and the backend guard;
non-admins keep a read-only detail view. Add a render test covering both the admin and
non-admin action bars.

Also strengthen the remote-deploy ordering test to assert global call order across spies
(create < compose < marker < deploy) via invocationCallOrder, not just per-method indices.
2026-05-29 15:12:48 -04:00
Anso eed7e04e71 fix(resources): harden Resources Hub data race, prune errors, and scan lifecycle (#1251)
* fix(resources): harden Resources Hub data race, prune errors, and scan lifecycle

Guard fetchAllData with a generation counter so switching nodes mid-load
cannot let a stale fetch overwrite the newly selected node's resources.

handlePrune now checks res.ok and surfaces the server error instead of
showing a false success toast on a failed prune, matching the delete and
purge handlers.

Make the vulnerability-scan poll loop abort-aware via an AbortController
that cancels on unmount and on node switch, dismissing the loading toast
and avoiding state updates after the view is gone.

Add developer-mode diagnostic logging to the prune, network-create, and
volume list/read paths, gated by the existing developer_mode setting and
never logging file bodies or secrets.

Add regression tests covering the node-switch race and the prune error
path.

* fix(resources): make scan-poll abort cancel in-flight requests and guard parse window

Thread the scan AbortController signal into the scan POST, status poll, and
image-summaries fetches so an abort cancels the in-flight request and the
loading toast clears promptly instead of after the request settles.

Add abort checks after each response-body parse so an abort landing during
JSON parsing cannot fire a stale completion toast, open the scan sheet, or
write summaries for a node the user already left.

Bump the fetch generation on unmount so a fetchAllData that resolves after
the view is gone drops its state writes and load-error toast.

Add a regression test for the post-unmount load-failure path.
2026-05-29 11:47:33 -04:00
Anso 96c5f05cdc fix(app-store): harden template deploy, registry fetch, and catalogue refresh (#1250)
* fix(app-store): harden template deploy, registry fetch, and catalogue refresh

Serialize generated compose through the YAML emitter so registry-supplied values are escaped correctly instead of interpolated into hand-built lines. Cap the registry response size so an oversized or runaway catalogue cannot exhaust backend memory, and surface the fetch failure to the caller. Reload the catalogue when the active node changes, since the registry is node-scoped. Add developer-mode deploy diagnostics (counts only, no values) and extend the unit tests with YAML round-trip, LinuxServer.io mapping, and size-cap coverage.

* fix(app-store): reset node-scoped catalogue state on fetch and bound deploy diagnostics

Clear the templates list and Trivy availability at the start of each catalogue load so a failed fetch after a node switch shows the new node's empty state instead of the previous node's catalogue or scan toggle. Bound the developer-mode diagnostic template title/source length, and document that the registry cache serves the last-known-good catalogue on a transient fetch failure (the size cap still protects memory in every case).
2026-05-29 11:46:51 -04:00
Anso b33a0e8422 fix(deploy-enforcement): surface scan-policy blocks on update and sidebar deploys (#1248)
* fix(deploy-enforcement): surface scan-policy blocks on update and sidebar deploys

A blocked deploy only opened the policy dialog from the editor deploy
button. The update action and the sidebar context-menu deploy/update
fell through to a generic error toast, so an admin could not review the
violations or bypass the block from those entry points. Route the 409
policy response through a shared handler on all three paths and make the
"Deploy anyway" bypass retry the originating action (deploy or update)
so an update bypass still re-pulls images.

Also:
- Correct the "Block on deploy" policy-editor helper text, which
  described post-deploy alerting rather than the pre-flight rejection it
  actually performs.
- Dispatch the documented scan_finding warning (policy name and the
  offending images) when a scheduled auto-update or auto-start is
  blocked, instead of recording an opaque failure.
- Add a standard log line when the gate blocks a deploy, plus
  developer-mode diagnostics for the matched policy and per-image
  severity decision.
- Fix deploy-enforcement docs: complete the enforced entry-point list,
  correct the policy-precedence wording, and remove inaccurate tier and
  audit-actor claims.

* fix(deploy-enforcement): surface policy block on rollback and name images in remote auto-update alert

Addresses two gaps found in independent review:

- Rollback is a policy-gated deploy path (it restores the saved files then
  re-runs the gate before redeploying), but the frontend treated a blocked
  rollback as a generic error toast. Route the 409 through the same handler
  as deploy and update so the block dialog opens, and let an admin "Deploy
  anyway" retry the rollback with the bypass flag (the rollback route already
  honors it).
- The remote auto-update path dispatched its policy-block warning without the
  offending image refs, unlike the local scheduler. Append the images so the
  alert matches the documented contract on every node.

Also list rollback as an enforced entry point in the docs and clarify that
Git Source enforcement covers both the create-time deploy and a manual
apply-with-deploy.
2026-05-29 08:48:50 -04:00
Anso 5dea040ec8 fix(deploy-progress): decouple deploys from the live progress stream (#1246)
* fix(deploy-progress): decouple deploys from the live progress stream

The deploy progress modal streamed compose output over a WebSocket, but
the deploy itself was coupled to that socket in two ways that could break
or silently abort a deploy:

- The deploy request was gated on the progress socket connecting, so any
  upgrade failure (a reverse proxy blocking WebSocket upgrades, or the
  admin-only stream rejecting a scoped deployer) left the modal stuck on
  "Connecting..." and the deploy never fired.
- The backend terminated the running compose process when that socket
  closed, so minimizing the modal, navigating away, or a network blip
  aborted an in-flight deploy.

Make the progress socket output-only: the deploy is owned by its request
and runs to completion (or the existing command timeout) regardless of
the stream. The modal now degrades to a "Live progress unavailable" state
and still reports success or failure from the request result. Connect
failures, drops, and a connect timeout all release the deploy instead of
blocking it.

Also route progress output per deploy: the frontend sends a correlation
id on both the connectTerminal message and the deploy request header, and
the backend keys progress sockets by that id so concurrent deploys from
different tabs or users no longer cross-stream each other's output.

Cap the in-memory parsed log rows so a very long deploy cannot grow the
modal's state unbounded.

* fix(deploy-progress): generate the deploy session id with a CSPRNG

The per-deploy correlation id keys which WebSocket receives a deploy's
live output, so a guessable id lets one authenticated client register a
victim's id and read its compose output. It was built from Math.random()
plus a timestamp, which is not cryptographically secure.

Generate it with crypto.getRandomValues (128 bits, hex). That is the one
Crypto member available in insecure contexts, so it still works over LAN
HTTP where crypto.randomUUID is unavailable.

* fix(deploy-progress): stop headerless ops bleeding into a keyed progress modal

Address review findings on the progress-stream routing:

- Only an id-less connectTerminal registration may become the id-less
  fallback socket. Previously every connectTerminal (including keyed deploy
  modals) set the fallback, so a headerless operation (bulk update, rollback,
  or a legacy client) resolved via getTerminalWs() into another user's keyed
  deploy modal. Keyed sockets are now excluded from the fallback, and a socket
  that adopts a session id is removed from it.
- The connect-timeout fallback now also flags the modal as "Live progress
  unavailable" instead of leaving it on "Connecting..." while the deploy runs.
- Log only a short prefix of the deploy session id in developer diagnostics,
  not the full capability value.
2026-05-28 20:51:45 -04:00
Anso b034de58f3 fix(auto-heal): gate panel write controls on admin role (#1245)
The Auto-Heal panel rendered its add-policy form, the per-policy enable
toggle, and the per-policy delete button without any admin guard, but
POST/PATCH/DELETE on /api/auto-heal/policies enforce requireAdmin +
requirePaid. Paid non-admin users could see the controls and would hit a
403 on click. Same drift class as the Schedule task gate already moved
in the prior sidebar parity pass.

Gate the add-new-policy section, the enable toggle, and the delete button
on the admin role. Reading existing policies and viewing history stay
available to every paid user, matching the backend GET routes which are
requirePaid only.

Docs updated to call out the admin requirement on Auto-Heal write
actions and to add a troubleshooting accordion for non-admin operators
who see the panel without the configuration controls.
2026-05-28 15:27:40 -04:00
Anso 0a8e6a79ae fix(sidebar): cancel pending debounce emit on external value reset (#1244)
SidebarSearch's value-sync effect adopted external resets but left the
pending setTimeout in place. When a clear or filter-driven reset arrived
inside the 120ms window, the stale timer would fire after the adopt and
emit the previously-typed query back to the parent, silently undoing the
reset. The skip condition also leaned on lastEmittedRef, which kept a
genuine reset from winning if its value happened to equal the last emit.

Switch the skip to compare the parent value against the locally shown
value (tracked through a ref so the effect deps stay on [value]). On any
external transition the effect now clears the pending timer before
adopting, removing the race entirely. lastEmittedRef is dead under this
model and is removed.

Adds a fake-timer test that types mid-window, rerenders with a different
value before the debounce fires, advances past the original deadline, and
asserts the parent never receives the stale emit.
2026-05-28 15:27:26 -04:00
Anso 979181875d fix(sidebar): require admin role for Schedule task and debounce search input (#1243)
The right-click Schedule task menu item and its keyboard shortcut were gated
only on isPaid, but the backend write routes under /api/scheduled-tasks
enforce requireAdmin + requirePaid on every action. Non-admin Skipper or
Admiral users would see the menu item and hit a 403 on click. The frontend
now mirrors the backend by gating Schedule task on isPaid && isAdmin so the
affordance only renders for users whose action will actually succeed.

Also adds a 120ms keystroke debounce to the sidebar search input. The
useStackListState filter rebuild was previously running on every keystroke
because <Command shouldFilter={false}> disables cmdk's own filter and the
existing 250ms timer only debounces state-invalidate events. Visible input
stays immediate via local state; the debounced emit drives the filter pass.

Adds a regression guard that /api/stacks/statuses is short-circuited by the
remote-node proxy (covers the sidebar status poll path) and updates the
sidebar feature docs to reflect the admin role requirement on Schedule task.
2026-05-28 14:16:46 -04:00
Anso adcd04b01a refactor(auto-update): retire per-stack gate, drive auto-update from schedules only (#1233)
* refactor(auto-update): retire per-stack gate, drive auto-update from schedules only

The per-stack Auto-update toggle in the stack sidebar context menu wrote a
gate row to `stack_auto_update_settings`, but actual updates only ran when a
`scheduled_tasks` row with `action='update'` fired. On a fresh install the
toggle was inert: detection ran every 6h, nothing was applied.

The same context menu already exposes `Schedule task`, which opens
ScheduledOperationsView pre-filled for the stack where the user can pick
`Auto-update Stack` and any cron. Keeping the toggle alongside that flow
duplicated the same action and turned the gate table into a parallel store
of "is a covering schedule active" derivable from `scheduled_tasks` itself.

Drop the gate model entirely:
- Backend: remove the `stack_auto_update_settings` table and its four
  accessors, the three routes under /api/stacks/*/auto-update, the per-stack
  skip in /api/auto-update/execute and SchedulerService.executeUpdate's
  fleet branch, and the clearStackAutoUpdateSetting call on stack delete.
  Dashboard `autoUpdate` count derives from scheduled_tasks (action='update'
  rows pinned to the node, total/enabled split).
- Frontend: drop the Auto-update entry from the sidebar context menu and its
  optimistic toggle plumbing. Drop autoUpdateSettings state, the
  /stacks/auto-update-settings fetch, and the auto-update-settings-changed
  WebSocket branch. Slim useSidebarActivitySummary (just nextRunAt; no
  enabled/total counts). AutoUpdateReadinessView's per-card autoUpdateEnabled
  now means "a covering enabled action='update' schedule exists" (per-stack
  row or fleet row on this node, earliest next_run_at wins, per-stack row
  wins on ties), with the gate-fetch removed.
- New: scheduledTasksRouter broadcasts scope: 'scheduled-tasks' on POST,
  PUT, PATCH /toggle, and DELETE so useConfigurationStatus and
  useNextAutoUpdateRun refetch under the 250ms debounce instead of waiting
  for the 60s poll. The broadcast is wrapped so a broken subscriber socket
  cannot turn a successful mutation into a 500.
- Docs: rewrite the "Per-stack control" section of auto-update-policies.mdx
  to describe the schedule-based model; update the matching troubleshooting
  entry. The misleading fleet-update help text in ScheduledOperationsView
  is corrected to reflect that every stack on the node is covered.

Tier parity: the surviving auto-update path (Schedule task -> Auto-update
Stack / All Stacks) is gated `requirePaid + requireAdmin` backend and
`isPaid + isAdmin` frontend, matching the gate the deleted routes carried.
The pre-commit grep returns no tier-related diff outside this PR's scope.

No data migration is provided: greenfield rules apply, and the leftover
table on already-shipped instances is harmless because no code reads or
writes it after this PR.

* docs: sweep remaining references to the per-stack auto-update toggle

The previous commit retired the per-stack Auto-update gate in favor of
configuring auto-update purely through scheduled tasks. This commit
removes the now-stale mentions of that toggle across the operator docs:

- docs/features/sidebar.mdx: drop the Auto-update entry from the Inspect
  group description, the matching screenshot alt-text, and the Skipper
  Note that listed it. Schedule task now carries the cross-link to
  Auto-Update Policies.
- docs/features/stack-management.mdx: drop the Auto-update list item;
  refresh the Schedule task entry to mention the Auto-update Stack action.
- docs/features/dashboard.mdx: rename the Configuration Status row from
  "Auto-update stacks" to "Auto-update schedules" with the new value
  shape, and rewrite the troubleshooting accordion to describe the
  scheduled-tasks invalidation path.
- docs/features/scheduled-operations.mdx: rewrite the Auto-update All
  Stacks row and helper text to reflect that every stack on the node is
  covered (no per-stack opt-out from this surface anymore).
- docs/features/multi-node.mdx: rewrite the Updates column definition to
  derive the Auto/Off flag from enabled Auto-update Stack / Auto-update
  All Stacks schedules instead of the removed per-stack policy.

The auto-update-policies.mdx rewrite in the previous commit already
covered the main reference page. The sidebar-context-menu.png screenshot
will be refreshed on release once the new menu is live in production;
the alt text is updated in this commit so it accurately describes the
shipping state.

No website edits needed: the Auto-Update Policies feature card description
("Schedule automatic image pulls and redeployments per stack on your own
cadence") and the feature matrix labels ("Auto-update stack schedule",
"Auto-update all stacks schedule") remain accurate under the new model.

* fix(stacks): drop orphaned requireAdmin import after auto-update route removal

CI's backend lint step flagged this PR's earlier deletion of the three
/api/stacks/*/auto-update routes: those handlers were the only callers of
`requireAdmin` inside routes/stacks.ts, leaving the named import on line 15
unreferenced. `requirePaid` and `effectiveTier` from the same line are still
in use elsewhere in the file and stay.

tsc --noEmit does not flag unused named imports; ESLint's no-unused-vars
does. Local backend lint reproduces and now reports 0 errors against the
existing 334-warning baseline.
2026-05-26 11:08:33 -04:00
Anso 2a29fed117 feat(labels): harden Stack Labels (gate parity, abort, dry-run, cap) (#1232)
* feat(labels): harden stack-labels surface (gate parity, abort, dry-run policy, cap)

- Cap labelIds array at MAX_LABELS_PER_NODE on PUT /api/stacks/:name/labels.
- Hide sidebar Labels submenu and Settings mutation buttons for roles
  without stack:edit, matching the backend requirePermission gate.
- Break the per-label bulk-action loop on req.aborted so cancelled requests
  release the per-node lock once the in-flight op completes.
- Reset saving state on success in LabelInlineCreateForm so the form stays
  interactive when reused outside the kebab/context menus.
- Invoke enforcePolicyPreDeploy inside the dry-run deploy branch and report
  blocked stacks honestly; previously dry-run skipped the gate and would
  predict success for stacks the real deploy would block.

* fix(labels): split sidebar gate so inline create matches unscoped backend guard

Codex review of #1232 surfaced a parity miss: POST /api/labels is guarded
by unscoped requirePermission('stack:edit'), but the sidebar inline "New
label" entry was gated by canEditLabels (per-stack scoped). An Admiral
user with only scoped grants on a stack could toggle existing labels but
the inline create request would 403.

Splits the sidebar gate:
- canEditLabels (scoped) keeps gating the Labels submenu trigger and toggle
  items, matching PUT /api/stacks/:name/labels.
- canCreateLabels (unscoped) now gates the inline "New label" entry,
  matching POST /api/labels.

Also restores the swallow-catch in LabelInlineCreateForm. The earlier
catch-to-finally change in #1232 let the rethrow from createAndAssignLabel
surface as an unhandled event-handler rejection in the browser console.
Parents already toast on failure; swallowing in the form is the intended
behavior with the finally reset still in place.
2026-05-25 23:48:12 -04:00
Anso 42e8d3a78c feat(security): per-image scroll + retention cap in scan history (#1231)
* feat(security): per-image scroll + retention cap in scan history

Long scan histories for hot images used to monopolise the Scan history
sheet: a single image with dozens of scans pushed every other image off
screen, and the underlying vulnerability_scans table grew without
bound.

Each image group's table now renders inside its own ScrollArea capped
at max-h-64 (~6 rows visible) so a busy image scrolls independently
while the list of images stays navigable. A new global setting
scan_history_per_image_limit (default 50, min 5, max 1000) backs both
a window-function query that caps the response per image_ref and a
prune step that runs on the existing MonitorService cleanup tick. The
response now carries cappedImageRefs + perImageLimit so the UI can
render a "Capped at N · older scans pruned" hint on groups sitting at
the ceiling without a second settings round-trip.

Single-image deep-dive (imageRef query param) bypasses the cap so a
user clicking into one image can still see its full history. The
prune uses self-contained subqueries to avoid SQLITE_MAX_VARIABLE_NUMBER
issues on first-run installs with large backlogs, and explicitly
deletes child rows from vulnerability_details, secret_findings, and
misconfig_findings inside a transaction since FK cascade is not
enabled at the connection level.

Settings → Developer → Data retention gains a "Scan history per image"
field.

* fix(security): skip searchDraft debounce on mount to stop page-reset race

The searchDraft debounce useEffect fires once on initial mount with the
unchanged value and, 300ms later, unconditionally calls setPage(0).
When a user (or a test) paginates inside that 300ms window, the
pending debounce silently undoes the page advance.

CI surfaced this as a flaky 3rd fetch in the "advances offset when the
user pages forward" test once the per-image cap work added enough
state-update overhead to push the click past the 300ms threshold on
the slower Linux jsdom run.

Track searchDraft with a ref and exit the effect when the value has
not actually changed, so the debounce only runs in response to real
user typing.
2026-05-25 23:44:31 -04:00
Anso 2d56ea958a fix(stack-activity): per-stack history integrity, attribution, sanitization (#1228)
* fix(stack-activity): per-stack history integrity, attribution, sanitization

Address the Stack Activity audit findings (PR 1 of 2):

- Per-stack history integrity: drop the per-insert 100-row prune in
  addNotificationHistory that evicted quieter stacks' history whenever
  another stack got chatty. Periodic cleanupOldNotifications now caps
  per (node, stack) at 500 rows and per-node unattached system events
  at 1000 rows, on top of the existing 30-day retention. Signature
  takes an options bag and returns a per-stage summary so MonitorService
  can log what actually ran each cycle.

- Actor attribution: thread req.user?.username through every
  notifyActionFailure call site and add synthetic actors at service
  emit sites (system:autoheal, system:scheduler, system:image-update,
  system:docker-events, system:blueprint, system:monitor, system:policy).
  The timeline renders system actors as "via <Label>" so an autoheal
  redeploy is no longer indistinguishable from a user redeploy.

- Message sanitization: new sanitizeNotificationMessage at
  NotificationService.dispatchAlert strips KEY=VALUE pairs whose key
  ends in TOKEN/KEY/PASSWORD/SECRET/CREDENTIALS/AUTH, scrubs HTTP basic
  auth in URLs and Bearer tokens, collapses COMPOSE_DIR paths, and
  truncates to 1000 chars. Applied to the stored history and to every
  downstream Discord/Slack/webhook channel. The ImageUpdateService
  recovery-path direct DB write also runs through the sanitizer.

- Composite pagination cursor: getStackActivity now accepts a
  (timestamp, id) cursor (?before=&beforeId=). The legacy timestamp-only
  form silently dropped events when a single compose up emitted many
  events sharing one millisecond. Route rejects beforeId without before.

- Frontend hardening: distinct error state with retry button (initial
  fetch failure no longer renders as the genuine empty state), strict
  positive-integer parsing on cursor params, overrequest-by-1 pagination
  so the last page does not leave a dead "Load more" click, runtime
  guard on liveEvents merge that validates the level union, per-minute
  day-bucket recompute so an open panel does not stay on "Today" past
  midnight.

No tier, role, or capability gate touched. Route permission gate
remains stack:read on the named stack.

* fix(stack-activity): sanitizer covers lowercase env vars and per-node compose dir

External review surfaced two leak paths in the message sanitizer:

- The sensitive-key regex was uppercase-only. Compose env names are
  conventionally uppercase but lowercase forms (db_password, jwt_secret,
  github_token) are valid and do leak through the same Docker and
  compose-parse error paths. Make the regex case-insensitive and tighten
  it to also catch bare TOKEN= / KEY= / PASSWORD= without a prefix word,
  while still leaving BYPASS, COMPASS, and similar non-secret keys alone.

- The compose-dir path collapse only read process.env.COMPOSE_DIR, but
  the real resolution chain is node.compose_dir (per-node DB override)
  -> process.env.COMPOSE_DIR -> /app/compose. A node with a custom
  compose_dir could still leak absolute paths into stored history and
  downstream channels. Route both the dispatchAlert call and the
  ImageUpdateService recovery-path direct write through
  NodeRegistry.getInstance().getComposeDir(localNodeId) so the
  collapse covers every resolution outcome.

Tests now assert lowercase keys are redacted and that BYPASS-style
non-secrets stay intact in both cases. notification-routing mock
extended to stub the new getComposeDir call.

* chore(stack-activity): a11y roles, visibility-aware tick, live-disconnect signal

Close three small follow-ups on the per-stack activity timeline:

- A11y: each day-group gets role="list" and each event row gets
  role="listitem" so screen readers traverse the timeline as a list
  instead of a wall of text. The day-group container also carries an
  aria-label naming the bucket.

- Visibility-aware day-bucket tick: the 60s setInterval that re-derives
  Today/Yesterday/Earlier now short-circuits when document.hidden, so a
  backgrounded panel does not re-render every minute for no visible
  effect.

- Live-disconnect signal: useNotifications dispatches a
  sencho:notifications-connection custom event on WebSocket open and
  close. The timeline listens and, when explicitly disconnected, shows
  a one-line "Live updates offline; reconnecting…" hint above the list.
  The sidebar ticker already surfaces fleet-wide connection state; this
  adds an in-context cue for users who are focused on a single stack.

Stack-name case normalization was considered and rejected: stack names
are case-permissive per the isValidStackName validator, and lowercasing
on read or write would silently rename or hide a user's "MyApp" stack.

* ci(stack-activity): drop unnecessary escape in URL_BASIC_AUTH regex

ESLint no-useless-escape errored on \- inside the character class
[a-zA-Z0-9+.\-] at notificationMessage.ts:14. Move the dash to the
end of the class so it's an unambiguous literal and the escape is no
longer required. Behavior is identical; sanitizer tests still pass.

* revert(stack-activity): drop unvalidated E2E spec from this PR

The spec was committed without ever running against a real Docker
daemon, then failed in CI when it ran for the first time: deploy
returned 200 but no notification appeared on the activity endpoint
within the polling window, suggesting either a deploy-notification
race or a node-id resolution mismatch in the CI environment.

Backend unit tests (route + composite cursor + sanitizer) and
frontend component tests cover the same logic. The E2E spec will
land in a dedicated follow-up once it has been authored against a
working CI environment.
2026-05-25 21:09:00 -04:00
Anso 117f590332 fix(security): gate admin-only scan affordances on isAdmin (#1230)
* fix(security): gate admin-only scan affordances on isAdmin

Backend already required admin for SBOM, SARIF, scan policies, Trivy
install/update/uninstall, the auto-update toggle, CVE suppressions, and
misconfig acknowledgements. The matching frontend surfaces were gated
only on isPaid (or only on isReplica), so non-admin users at the same
tier saw buttons that returned 403 on click.

Threads isAdmin from useAuth() into SecuritySection, SuppressionsPanel,
and MisconfigAckPanel. Updates the scan-result sheet caller in
ResourcesView so SBOM and SARIF render only when paid AND admin; passes
canManageSuppressions to the stack-misconfig sheet so admins can ack
misconfigs from that surface too.

Read paths remain visible to non-admins (policy list, suppression list,
ack list, scan history) since the GET routes are auth-only on both
sides.

* fix(security): close 3 remaining scan-sheet parity gaps from review

Code review surfaced three sites missed in the first pass:

1. SecurityHistoryView opened the scan sheet with canGenerateSbom set
   only on isPaid, so Skipper non-admins saw SBOM and SARIF buttons
   even though the backend requires admin+paid. Now ANDed with isAdmin.

2. ResourcesView passed onRescan unconditionally, and the sheet
   renders a Re-scan primary action whenever onRescan is defined.
   Non-admins reaching the sheet via the severity-badge shortcut saw
   the button; clicking it called POST /security/scan, which the
   backend requires admin for. onRescan is now undefined for
   non-admins.

3. ShellOverlays and ResourcesView passed canManageSuppressions=isAdmin
   without considering the replica gate, so a replica admin saw
   suppress and ack columns whose backend writes blockIfReplica. The
   sheet now probes /fleet/role internally and ANDs !isReplica into
   the effective canManageSuppressions, so the column hides on a
   replica regardless of how the caller wired the prop.

* fix(security): clear isReplica state on every scan-sheet probe

The previous probe only flipped the state to true on a replica response
and never wrote false on a control, non-OK, or skipped probe. With the
sheet kept mounted by ResourcesView, SecurityHistoryView, and
ShellOverlays, an admin who first viewed a scan on a replica would
keep suppress/ack controls hidden even after switching to a control
instance, because the stale true value persisted across re-opens.

The effect now resets isReplica to false at the start of every probe
and assigns the result of /fleet/role directly. Probe failures and
skips leave the state at false, so the UI is permissive and the
backend blockIfReplica guard remains the source of truth.
2026-05-25 18:33:04 -04:00
Anso 05c3975d6d test(dashboard): cover dashboard routes, ConfigurationStatus tier parity, and useMeshDataPlane (#1221)
* test(dashboard): cover dashboard routes, ConfigurationStatus tier parity, and useMeshDataPlane

The dashboard router had no dedicated Vitest coverage; tier parity in the
ConfigurationStatus component was only proved by manual inspection; and the
Admiral short-circuit in useMeshDataPlane had no automated regression net.

Add three spec files:

- backend/src/__tests__/dashboard-routes.test.ts: 11 cases against the live
  Express app. Both routes reject unauthenticated requests; the
  configuration response matches its documented shape; the tier x variant
  `locked` matrix is asserted end-to-end for Community, Skipper, and
  Admiral via LicenseService spies; a seeded Discord agent URL is shown
  never to appear in the serialized response; /stack-restarts clamps days
  values of 0, 999, and NaN without bailing.

- frontend/src/components/dashboard/__tests__/ConfigurationStatus.test.tsx:
  five render cases prove the parity contract. Community hides the entire
  Automation section plus the four gated rows (Notification routing,
  Webhooks, Scheduled tasks, Vulnerability scanning); Skipper shows
  everything except Scheduled tasks (Admiral-only); Admiral shows every
  gated row plus the SSO provider name mapping (oidc_google -> "Google").
  Skeleton and load-error paths are also covered.

- frontend/src/components/dashboard/__tests__/useMeshDataPlane.test.tsx:
  four hook cases prove the Admiral short-circuit. Non-Admiral sessions
  never fire /mesh/status; Admiral sessions fetch once and populate the
  localDataPlane payload; a 403 response leaves status null without
  raising; a response that omits localDataPlane also leaves status null.

Backend route suite + dashboard-only frontend suite green in isolation.
The full backend suite shows one pre-existing Windows-only EBUSY flake in
filesystem-backup.test.ts (SQLite file lock on unlink) that reproduces on
the unmodified branch tip and is unrelated to these changes.

* test(dashboard): drop backup.requiredTier from ConfigurationStatus fixture

The fixture's `backup.requiredTier: 'admiral'` field was authored to match
the type on this branch's original base. Main has since removed that field
from the ConfigurationStatus payload, so the fixture now over-specifies a
property the type forbids and fails tsc.

Drop the field to realign with the current type.
2026-05-25 12:14:33 -04:00
Anso 03a5826f7e fix(dashboard): debounce state-invalidate refetches (#1209)
* fix(dashboard): debounce state-invalidate refetches and drop redundant listener

useDashboardData fired three immediate HTTP requests (/stats, /system/stats,
/stacks/statuses) for every Docker container event. A burst restart of a
50-container stack produced ~150 instant requests against the local instance
with no throttle. Add a 250 ms trailing-edge debounce so an event storm
collapses into a single coalesced refresh, mirroring the precedent in
useNextAutoUpdateRun. The cleanup function now also clears any pending
debounce timer so a late event cannot fire after the dashboard unmounts.

useConfigurationStatus subscribed to the same event but its data is built
from settings and policy tables (agents, alert rules, auto-heal, scheduled
tasks, scan policies, backup config), none of which change on container
state. Drop the listener entirely; the 60 s poll catches rare settings
edits with acceptable latency.

* fix(dashboard): scope settings-event listener back into useConfigurationStatus

Address two follow-up findings from independent review of the earlier
commit on this branch.

1. Restore a filtered sencho:state-invalidate listener in
   useConfigurationStatus. The earlier commit dropped the listener wholesale
   to keep container-event bursts from refetching settings data, but that
   also silenced the only settings-affecting event in the current taxonomy:
   action='auto-update-settings-changed' (emitted from
   backend/src/routes/stacks.ts when a user toggles a stack's auto-update
   setting). With the listener gone, the Configuration Status row for
   Auto-update stacks could sit stale until the 60 s poll. The new
   listener mirrors the precedent in useNextAutoUpdateRun: filter on the
   single configuration-relevant action, trailing-edge debounce 250 ms.

2. Add an `active` flag to the useDashboardData state-invalidate effect.
   Cleanup already clears the pending debounce timer, but a refresh()
   already in flight could still call setters after unmount because the
   awaited Promise.all has no abort hook. The flag is checked both before
   the await and after, matching the cleanup shape used by
   useNextAutoUpdateRun.

Tests cover both: the configuration listener now ignores scope='stack' and
scope='image-updates' bursts and refetches once on a settings-changed
burst.
2026-05-25 12:13:17 -04:00
Anso 7c3ba3f24d feat(dashboard): surface metrics-stale indicator after sustained poll failure (#1213)
* feat(dashboard): surface metrics-paused indicator after sustained poll failure

useDashboardData previously failed silently when /stats or /system/stats
returned an error: stale data kept rendering and the last sync timestamp
quietly drifted. The operator could not tell whether the dashboard was just
slow or whether the Docker socket / metrics path had genuinely gone down.

Track consecutive failures per live-metrics endpoint. After three in a row
on either /stats or /system/stats (≈15 s at the 5 s poll cadence), expose a
metricsStale boolean on the hook result. HealthStatusBar renders a small
amber "metrics paused" chip beside the meta line when set. The indicator
clears on the first successful response when both endpoints are within the
threshold.

A unit test for the threshold logic is intentionally deferred to the Phase
4 E2E dashboard spec, which exercises the same path end-to-end by stopping
the Docker daemon and asserting the user-visible indicator.

* fix(dashboard): rename stale-metrics chip and cover the threshold with tests

Address two follow-up findings from independent review of the earlier
commit on this branch.

1. Rename the masthead chip from "metrics paused" to "metrics stale". The
   hook keeps polling on every cycle; the chip describes the freshness of
   the displayed numbers, not the polling cadence. The new wording matches
   the underlying `metricsStale` state variable.

2. Add a Vitest spec for the threshold logic. Captures the visibilityInterval
   callback at registration time and drives each polling cycle on demand,
   covering: three consecutive /stats failures trip the indicator and the
   next successful poll clears it; three consecutive /system/stats failures
   trip the indicator on the other endpoint; clearing requires both
   endpoints under threshold (a single endpoint recovering while the other
   is still failing keeps the indicator set).

The clarifying comment in useDashboardData notes that polling is unaffected
and only the data freshness is in scope, so future readers do not interpret
"stale" as "paused".
2026-05-25 12:11:43 -04:00
Anso e183153a64 chore(dashboard): drop misleading backup.requiredTier from configuration payload (#1212)
Cloud Backup has a per-provider tier: Custom S3 is open to every tier
(PR #1143) while Sencho Cloud Backup requires Admiral. A single
backup.requiredTier='admiral' on the configuration response misrepresented
that split, and no consumer ever read the field. Remove it from the
response interface and the response builder; update the frontend mirror
type accordingly. Annotate the backup block so the per-provider intent is
clear at the call site.
2026-05-25 12:11:17 -04:00
Anso 0db0d29f3b fix(dashboard): decouple FleetHeartbeat refresh from the active local node (#1210)
useFleetHeartbeat keyed its effect on activeNode.id and reset its state on
every node switch, even though /fleet/overview returns a fleet-wide payload
that does not change when the user pivots their active local node. The
result was a needless flicker back to the skeleton card and an extra HTTP
request on every node pivot.

Drop the nodeId dependency and the stale-node guard ref. The 30 s
visibility-interval poll remains, so transient remote-node offline state
still surfaces within one polling cycle.
2026-05-25 12:10:55 -04:00
Anso 6d995b9aaf fix(dashboard): slow HealthStatusBar sync-label tick to 5s (#1211)
useTicker(1000) rendered the masthead once per second to advance the "last
sync Xs" label. The label only shifts visibly every few seconds (1s, 6s,
11s..., then m, then h), so the per-second cadence forced the entire
dashboard tree through the React reconciler 60 times per minute for a
visual change the eye does not see.

A 5s tick keeps the label fresh while cutting the wake-up rate by 5x. Name
the constant so the trade-off is documented at the call site.
2026-05-25 12:10:48 -04:00
Anso ca144f07d9 chore(dashboard): drop unused AgentStatus exports on both sides (#1222)
The Phase 5 dead-code sweep across the dashboard call graph found two
identically shaped findings: the AgentStatus interface is declared and
exported in both backend/src/routes/dashboard.ts and
frontend/src/components/dashboard/useConfigurationStatus.ts, but no other
file imports it. (The frontend StackAlertSheet component has a separate,
differently shaped private AgentStatus that does not refer to either of
these.)

Drop the export keyword on both. The interfaces stay alive as
file-internal types, the public surface shrinks by two names, and no
behaviour changes.

The broader payload-cleanup opportunities surfaced during the sweep
(unused requiredTier fields on individual row objects, unused top-level
tier and variant fields on ConfigurationStatus) are out of scope for this
PR and have been filed as Linear roadmap items.
2026-05-25 12:10:33 -04:00
Anso d8b6f8cf3b feat(stack-files): force-text override for misidentified binary files (#1215)
* feat(stack-files): force-text override for misidentified binary files

The binary-detection heuristic (30% non-printable / NUL in the first
8 KB) sometimes flags UTF-8 files that happen to carry an embedded NUL
or a high non-printable ratio, locking the user out of inline editing
with only a Download fallback.

readStackFile now accepts an optional { forceText: true } that bypasses
isBinaryBuffer on the small-file path and returns the bytes as UTF-8
content. The route exposes this as ?force=text on GET /files/content.
The oversized branch deliberately stays untouched: returning a multi-MB
file as JSON-encoded text is wasteful regardless of the heuristic.

SpecialFilePanel grows an optional extraAction slot. The viewer's
binary branch wires Open as text anyway, which refetches with the new
flag, clears isBinary, and routes the content through the existing
Monaco editor path. A failed override surfaces both an inline error
panel and a toast so the user knows why the click did nothing.

Backend tests pin the heuristic-vs-override behaviour pair on a file
with a literal NUL byte. The frontend test asserts that the second
readStackFile call carries forceText: true and that Monaco mounts.
Troubleshooting accordion entry updated to mention the new affordance.

* fix(stack-files): guard the binary-override path against oversized files

The override on the binary panel could open Monaco against an empty
content buffer if the backend's oversized branch ran (files past the
2 MB inline-preview cap intentionally carry no content even when
force=text is set). Saving that empty buffer would wipe the file on
disk.

Two reinforcing changes:

- Initial load now checks result.oversized before result.binary, so a
  file that is both oversized and has binary bytes in the 8 KB probe
  shows the Download panel rather than the binary panel. The size
  signal stays in front of the operator and the override button never
  surfaces for a file that cannot be safely opened inline.

- The handleForceText handler now respects result.oversized on the
  refetch and transitions to the Download panel instead of clearing
  isBinary and copying result.content ?? '' into Monaco.

Same handler also gains a stale-request guard via a selectedPathRef:
a slow override for file A no longer stomps on file B's state if the
user navigated away while the request was in flight.

Two regression tests pin the new behaviour: oversized+binary surfaces
the Download panel on initial load, and an oversized refetch from the
binary panel routes to the Download panel rather than Monaco.
2026-05-25 01:38:04 -04:00
Anso fcf2222604 feat(stack-files): cap directory listings at 1000 + add file-tree filter (#1208)
* feat(stack-files): cap directory listings at 1000 + add file-tree filter

The file-tree route returned every entry in a directory unbounded.
A logs/ or data/ subfolder with rotated artifacts could produce a
multi-megabyte response and a frontend cap at 500 entries silently
hid the rest with no way for the user to find a specific file.

The list route now caps the response at 1000 entries (the audit's
recommended bound), advertises the unfiltered total via
X-Total-Count, and sets X-Truncated when truncation happened. The
service exposes both a bare-array listStackDirectory (unchanged
contract for callers that just want the array) and a paginated
listStackDirectoryPage that returns {entries, total, truncated}.

The FileTree now offers a search input above the scroll area that
filters loaded entries by name (case-insensitive substring). Clearing
the filter restores the full listing. A non-matching filter shows a
short hint instead of an empty pane. The client-side MAX_ENTRIES
matches the server cap so a perfectly-sized directory never shows
the truncation hint.

* fix(stack-files): filter keeps parent dirs when loaded descendants match

The original filter applied per-render-level inside renderEntries, so a
parent directory whose name did not match was filtered out even when one
of its already-loaded children did. The match was then unreachable: the
parent had been removed from the visible list and its children never got
a chance to render.

Compute matching-descendant once per directory by walking the loaded
dirContents map (no extra fetch, bounded by what the user already
expanded). Keep ancestors of any match in the visible list. Auto-expand
those ancestors for the duration of the filter so the match comes into
view without a manual click on every parent.

Filter scope is still 'what is already loaded'; unexpanded subtrees do
not contribute to ancestor-keep until the user expands them. Two new
tests pin both behaviours.
2026-05-25 00:03:09 -04:00
Anso ea002cd9a0 feat(stack-files): drag-and-drop upload zone (#1207)
* feat(stack-files): drag-and-drop upload zone

The Files-tab dropzone was a click-only button. Drag-and-drop is the
standard file-manager affordance and the audit doc named its absence
as a known gap.

The same dropzone now accepts file drops, gates the affordance on
canEdit, and reuses the existing handleFile pipeline so all the
existing rules apply: 25 MB cap, server-side path validation,
multi-file drops rejected up front with a clear toast (the upload
route only handles one file per request). dragenter+dragover light
the zone in the brand color and swap the label to "Drop to upload";
dragleave honours bubbling from child nodes so the highlight does not
flicker when the cursor crosses an inner icon.

Drag events without a Files payload (text selection, image drag from
another page) are explicitly ignored so the zone does not light up
for non-file content.

* test(stack-files): widen upload-zone E2E selector to match new label

The drag-drop work renamed the dropzone label from 'Upload file' to
'Upload or drop file' (and 'Drop to upload' during hover). The E2E
test in stack-files.spec.ts filtered on the literal /upload file/i,
which no longer matches the new label and the test timed out at the
visibility assertion.

The hidden file input's aria-label='Upload file' was deliberately
preserved, so every other locator in the spec (input[aria-label],
getByLabel) keeps working. Only the role='button' filter needed the
update.

Widen the regex to /upload/i so future copy edits do not re-break
this assertion.
2026-05-24 23:54:35 -04:00
Anso 4964320f50 fix(stack-files): optimistic concurrency on file-tab writes via mtime ETag (#1206)
* fix(stack-files): optimistic concurrency on file-tab writes via mtime ETag

PUT /api/stacks/:name/files/content previously did a blind write; two
operators editing the same script lost one of the saves with no
warning. The compose-file editor already had mtime optimistic
concurrency (PR #1183); this brings the file-explorer write path to
the same shape.

GET /files/content now also returns mtimeMs and sets a weak ETag header
derived from the stat. The matching PUT reads If-Match, asks
FileSystemService.writeStackFileIfUnchanged to compare against the
live mtime, and returns 412 PRECONDITION_FAILED with the current
content and mtime when the stale-write check fails. Successful writes
echo a fresh ETag so the client can pin the next save without re-GET.

readStackFile and writeStackFileIfUnchanged each open the file once
and stat+read through the same handle so the mtime returned to the
client matches the bytes that were sent, even if the file is replaced
between the two operations.

PUT without If-Match still succeeds (backward compatibility with
scripted clients that do not roundtrip the ETag). FileViewer now sends
the loaded mtime on save, updates its local mtime from the success
response, and on FileConflictError adopts the server snapshot as the
new baseline so the user's follow-up edit-and-save does not loop on
the same precondition.

* fix(stack-files): treat deleted-target as conflict; preserve user buffer on conflict

Two follow-ups from code review on the prior commit:

- writeStackFileIfUnchanged now returns ok:false when expectedMtimeMs is
  set and the target has been deleted. The caller was editing a file
  that no longer exists; silently writing the buffer to the void is
  wrong. The client adopts the empty snapshot as 'file is gone, start
  over' and the user keeps control of what to save next.

- The FileViewer conflict handler no longer overwrites the user's
  typed buffer with the server snapshot. It updates the baseline so
  the next save sends the fresh mtime, then leaves the editor content
  alone. The user sees their edits, the Save button stays enabled,
  and a follow-up click applies their changes on top of the new
  server version without silently destroying what they typed.

* fix(api): preserve default headers when caller supplies a headers field

apiFetch built defaultOptions.headers by merging Content-Type, x-node-id,
and the caller's headers, but then spread the unmodified fetchOptions
over defaultOptions at the outer level. The spread overwrote the merged
headers with the caller's bare headers, silently dropping Content-Type
on every request that supplied any custom header.

This was latent until the file-explorer save path started sending an
If-Match header. The Express body parser refused the PUT without
Content-Type, the route returned 400, the editor showed an error
toast instead of the success toast, and the Playwright save assertion
timed out.

Destructure headers out of fetchOptions before the outer spread so the
already-merged defaultOptions.headers survives. Add api.test.ts with
four regression cases pinning Content-Type, the If-Match merge,
x-node-id presence when active, and localOnly skip.
2026-05-24 23:44:24 -04:00
Anso 3e56696c91 fix(stack-files): prompt before discarding unsaved edits on file switch (#1203)
FileViewer tracks dirty state via content !== originalContent, but
StackFileExplorer would swap selectedPath on every tree click and silently
drop the buffered edit. A user editing a script and clicking a sibling
file lost their work with no warning.

FileViewer now exposes onDirtyChange so the explorer learns when the
viewer is dirty. StackFileExplorer intercepts the tree-node click: if
dirty, the next selection is stashed and a ConfirmModal asks whether to
discard. Confirm applies the stash, Cancel keeps the current file.
Clicking the already-selected file is a no-op (no spurious prompt).

The dirty signal is reported via a ref so future consumers passing an
inline callback identity each render do not retrigger the unmount
cleanup effect.
2026-05-24 23:25:52 -04:00
Anso c8b095b887 fix(stack-files): confirm before overwriting an existing upload target (#1204)
* fix(stack-files): confirm before overwriting an existing upload target

Same-name uploads previously truncated the existing file silently. A
user dragging a file with a name that matched an in-place file
destroyed the original with no warning and no undo.

The upload route now reads ?overwrite=0|1. When the flag is not set
and the target already exists, the server returns 409 FILE_EXISTS
and the original file is untouched. The frontend opens a confirm
dialog and retries with overwrite=1 on the user's approval; cancel
keeps the original.

A new pathExists helper on FileSystemService performs the existence
check through the same path-resolution barrier as the write so a
malicious relPath cannot bypass the conflict check. UploadConflictError
is exported so callers can distinguish the conflict case from generic
upload failures without parsing error strings.

* fix(stack-files): distinct DIR_EXISTS code, drop INVALID_PATH swallow in existence check
2026-05-24 23:17:22 -04:00
Anso 4c28b37a59 fix(stacks): require stack:read on file explorer GET routes (#1200)
The four file-explorer GET endpoints (list, content, download,
permissions) previously relied on auth alone. Their write-side siblings
required stack:edit, so the read path was the only file-explorer surface
without an explicit capability check. The shipped roles all carry
stack:read globally so behaviour is unchanged today, but adding the
guard prevents a future role definition from silently inheriting
unrestricted file reads, and it brings the file-explorer reads in line
with gitSources and stackActivity which already gate on stack:read.

The frontend Files tab trigger, panel, and the anatomy-panel "Open
Files" affordance now render only when the user holds stack:read for
the active stack. An effect canonicalises activeTab back to 'compose'
if the user lands on 'files' without permission, so a denied user
cannot end up staring at an empty panel.
2026-05-24 22:58:28 -04:00
Anso 5aedc52737 feat(stacks): server-side POST /api/stacks/bulk endpoint (#1185)
* feat(stacks): server-side POST /api/stacks/bulk

The frontend's bulk action UI fanned out N parallel POSTs to
/api/stacks/:name/{start,stop,restart,update}. For 30 stacks on a
remote node that was 30 round-trips through the proxy + auth + audit
chain, with no shared mutex and partial-failure UX bolted on the
client.

The new endpoint accepts {action, stackNames} (max 100 names), runs
ops under bounded parallelism (4 concurrent), reuses the per-(nodeId,
stackName) lock from the lifecycle-mutex change so collisions report
stack_op_in_progress as a per-row outcome, and returns
{action, results: [{stackName, ok, error?, code?}, ...]} with a 200
envelope. Per-stack errors are rows, not response codes.

Update action keeps the policy-enforcement check (per-stack, returns
policy_blocked rows) and the post-deploy scan trigger so the
single-stack security contract is preserved. State-invalidate and
image-update notifications fire per successful row so the activity
timeline and image-updates UI reflect bulk operations the same way
as single-stack ones.

Frontend useBulkStackActions swaps the Promise.allSettled fan-out for
a single call; the per-stack toast aggregation moves to reading the
results array. isPaid pre-flight stays in place to avoid a round trip
for Community-tier users on update.

* chore(stacks): dedupe bulk inputs; document tier asymmetry; regression test

Three follow-ups from independent review:

- Dedupe stackNames before scheduling so a payload like ['web','web']
  produces one row, not one ok-row plus one stack_op_in_progress row
  whose presence depended on worker scheduling.
- Add a route-ordering regression test verifying that a stack literally
  named 'bulk' is still reachable via /api/stacks/bulk/restart. Express
  matches the literal /bulk before /:stackName paths only at the
  no-suffix level; the :stackName/restart route still catches it.
- Comment the deliberate tier asymmetry: bulk update is requirePaid;
  single-stack /:stackName/update is open to all tiers. The fan-out
  blast radius is the reason, and it matches the prior frontend gate.

Existing 'policy_blocked per-row' test now uses the real ScanPolicy /
PolicyViolation / PolicyEnforcementResult shapes (the first cut elided
fields tsc strict-checked).
2026-05-24 15:56:53 -04:00
Anso 27b8954676 feat(deploy-panel): tell Community operators deploys lack auto-rollback (#1193)
* feat(deploy-panel): tell Community operators deploys lack auto-rollback

Atomic-deploy is paid-only (effectiveTier === 'paid' in the deploy and
update routes); Community deploys proceed without the backup/restore
fallback. The UI never told the user. They only learned the difference
when a deploy failed and there was nothing to roll back to.

Add a one-line muted-style notice strip inside the deploy-feedback
modal, between header and log body, shown only when the user is on
Community AND the action is a deploy or update (the two paths that
support atomic on paid).

Copy is deliberately one line and states the requirement once:

  "Auto-rollback on failure is a Skipper feature."

Compliant with Directive 31: it does not enumerate where the feature
is hidden, it does not say "you don't get it", it states what the
upgrade unlocks. Other tier-named upgrade prompts in Sencho follow
the same pattern.

Resolves M-3 from the stack-management audit.

* fix(deploy-panel): mount DeployFeedbackPortal inside LicenseProvider

The portal was mounted at App level, outside the authed AppContent
tree where LicenseProvider lives. After this PR introduced
useLicense() inside DeployFeedbackModal (for the atomic-deploy
notice), every test that opened the modal hit:

  Error: useLicense must be used within a LicenseProvider

caught by ErrorBoundary and surfaced through every deploy-log-panel
E2E spec.

Move the portal inside LicenseProvider in AppContent. DeployFeedback-
Provider stays at App level so its state survives across re-renders
of AppContent; the portal still inherits it because AppContent is a
descendant.

A deploy can only fire after authentication, so rendering the portal
only inside the authed tree loses nothing in practice.
2026-05-24 15:56:40 -04:00
Anso fbd13accda feat(stacks): optimistic concurrency on compose and env file writes (#1183)
* feat(stacks): optimistic concurrency on compose and env file writes

Two browser tabs (or one tab + an out-of-band edit) could silently
overwrite each other's compose.yaml or .env edits. GET /api/stacks/:name
and GET /api/stacks/:name/env now emit a W/"<mtime>" ETag header. PUT
on the same endpoints reads If-Match and returns 412 with
{code: 'stack_file_changed', currentMtimeMs, currentContent} on a stale
write, so the editor can recover without losing the user's text.

The 412 path in the editor surfaces a confirm dialog: "Overwrite
their changes?" Cancel loads the latest content into the editor and
exits edit mode. OK retries the PUT with no If-Match header.

If-Match is optional. A client that doesn't send it falls through to
the previous unconditional-write behavior so partial deploys (file
explorer uploads, git source sync) don't gain a surprise 412 surface.

* fix(stacks): consistent stat+read for compose mtime via held file handle

Promise.all([readFile, stat]) lets a concurrent write interleave between
the two calls: the read can return new content while the stat returns
the old mtime (or vice versa). The next If-Match check would then either
spuriously trigger 412 or silently allow an overwrite.

Hold the file descriptor open across stat and read so both ops observe
the same inode state. A rename-replace by another writer would not
affect the held fd's view.

Adds a near-boundary mtime test (1-second bump) to confirm the
Math.floor comparison detects whole-second changes on filesystems that
round to second-level precision.

* fix(stacks): defense-in-depth path validation in new compose mtime methods

CodeQL's taint engine on PR #1183 flagged 10 js/path-injection errors
across the four new FileSystemService methods (getStackContentWithMtime,
saveStackContentIfUnchanged, writeFileIfUnchanged, statMtime). The
engine does not follow the existing assertWithinBase guard across the
resolveStackDir / getComposeFilePath helper boundary; from its view the
stackName flows straight from req.params into a filesystem sink.

Eight alerts (getStackContentWithMtime + saveStackContentIfUnchanged)
were CodeQL blind-spot: the path WAS validated inside resolveStackDir.
Two alerts (writeFileIfUnchanged + statMtime) were genuinely missing
service-level guards because those methods accept a raw targetPath
from the caller and trusted the route to have validated upstream.

Add an explicit this.assertWithinBase(filePath) at the top of each
new method. The check is redundant for the two methods that already
went through resolveStackDir but makes the safety boundary visible
both to readers and to CodeQL's taint follower.

While here, port the held-file-descriptor pattern (already applied to
getStackContentWithMtime in dd7545eb) to the stat-then-read sequence
in saveStackContentIfUnchanged and writeFileIfUnchanged. This closes
the two js/file-system-race warnings on those branches so a concurrent
rename-replace cannot interleave between the mismatch detection and
the currentContent capture returned in the 412 payload.

The two remaining js/http-to-file-access warnings ("write to file
system depends on untrusted data") are semantic and intentional: this
is the save endpoint by design. They stay as warnings (not errors),
do not block CI, and are not suppressed because the codebase does not
do CodeQL suppression annotations.

* fix(stacks): inline path.resolve+startsWith barrier per CodeQL recommendation

The previous fix added this.assertWithinBase() calls at the top of each
new method. CodeQL's taint-flow analysis does not follow that helper
call across the function boundary, so it still saw the path as
user-tainted at every fs sink (10 -> 8 alerts after the first attempt).

CodeQL's documented js/path-injection sanitizer recognizes the
following inline pattern:

  filePath = path.resolve(ROOT, filePath);
  if (!filePath.startsWith(ROOT)) { throw / return; }
  // use the reassigned filePath below

The key elements are (1) path.resolve as the normalizer, (2) startsWith
check, (3) reject inline, (4) downstream use of the reassigned
variable. None of these can hide behind a helper call or the taint
tracker re-flags every sink.

Inlines the pattern in each of the four new methods (getStackContentWith-
Mtime, saveStackContentIfUnchanged, writeFileIfUnchanged, statMtime).
The check stays runtime-correct (it's the same logic isPathWithinBase
implements) but is now visible to static analysis.

writeFileIfUnchanged and statMtime had no service-level guard at all
before this commit (they trusted the caller); the inline barrier closes
that real gap as well as the CodeQL-recognition gap.

* fix(stacks): canonical CodeQL js/path-injection barrier shape

The prior inline check used path.resolve(filePath) with a single
argument and a compound condition (a !== b && !c.startsWith(d)).
CodeQL's path-injection sanitizer recognizer is shape-sensitive: it
matches path.resolve(SAFE_ROOT, untrusted) with the safe root as the
first argument, followed by a single unary startsWith check on the
resolved variable. The compound form and the single-arg resolve fell
outside the recognized pattern, leaving 8 alerts unchanged across the
four new methods.

Rewrite the barrier in each method to match the documented shape
verbatim:

  const baseResolved = path.resolve(this.baseDir);
  const safePath = path.resolve(baseResolved, untrustedInput);
  if (!safePath.startsWith(baseResolved + path.sep)) {
    throw ...;
  }
  // sinks consume safePath

baseResolved is a local variable (anchors the resolve call against a
known-safe root). safePath is the reassigned, sanitized variable that
every downstream fs.* call consumes. The single startsWith check with
path.sep appended prevents the prefix-match edge case (/foo matches
/foobar without the separator). All four affected methods get the
same form.

This is the third attempt at the CodeQL fix. The first added an
assertWithinBase helper (function call, not followed across the
boundary). The second inlined path.resolve(x) with a compound check
(non-canonical shape). This commit uses the literal recommended
sanitizer.
2026-05-24 15:48:17 -04:00
Anso d727a55a5f feat(stacks): surface post-deploy scan attempt status (#1198)
triggerPostDeployScan was fire-and-forget. When Trivy was missing on a
node, when the registry refused the digest lookup, or when a single
image scan threw, the failure went to console.error and the user
never learned. Open the security tab later, see stale data, no
indicator that the scan even tried.

Backend:
- New stack_scan_attempts table (node_id, stack_name, status,
  attempted_at, error_message). One row per stack; latest attempt
  overwrites the previous one.
- DatabaseService gains recordStackScanAttempt /
  getStackScanAttempt / clearStackScanAttempts. Status is one of
  'ok' | 'partial' | 'failed' | 'skipped'.
- triggerPostDeployScan in helpers/policyGate.ts now records every
  exit path: 'skipped' when Trivy is unavailable or no images to
  scan; 'failed' when container enumeration or all images fail;
  'partial' when some images scan and others fail; 'ok' on full
  success.
- New GET /api/stacks/:name/scan-status returns { status,
  attemptedAt, errorMessage } or { status: null } when never tried.
- DELETE /:stackName cleanup chain now clears the row alongside
  the existing update-status / auto-update cleanups.

Frontend:
- StackAnatomyPanel fetches /scan-status on stackName change.
- Renders a small warning strip below the update banner when
  status !== 'ok' (failed / partial / skipped). Hidden when status
  is 'ok' or unknown (never attempted). Title attribute carries
  the full error message for hover inspection.

Cross-feature note: the audit doc flagged this as M-6 with a
coordination note for the pending Security feature audit. The
schema kept intentionally narrow (one row per stack, simple
status enum) so the Security audit can extend it (richer history,
per-image-row breakdown, etc.) without a destructive migration.

Resolves M-6 from the stack-management audit.
2026-05-24 15:44:12 -04:00
Anso 5196f0440e feat(sidebar): surface unreachable nodes in cross-node stack search (#1195)
Per-node fetch failures in useCrossNodeStackSearch were silently
swallowed into []. The user could not tell the difference between
"this node has no matching stack" and "this node timed out or 502'd".

Adds a third return value to the hook: failedNodes: FailedNode[]
where FailedNode is { nodeId, nodeName, reason }. The reason captures
the HTTP status text (e.g. "list returned HTTP 502") or the
underlying Error message ("connect ECONNREFUSED 192.168.x.x:1852")
without leaking node URLs. AbortError from the effect cleanup path
is intentionally excluded - it's expected when the user keeps typing.

Threads the new field through useStackListState and EditorLayout into
StackList. StackList renders a warning chip below the "Other nodes"
header when the array is non-empty:

  ! N nodes unreachable    > (expand)

Click expands the chip to a vertical list of "node: reason" lines.
Hover surfaces the full reasons in the title attribute too, so a
user inspecting at a glance can read them without clicking. The chip
is suppressed entirely when the search yields no failures (no zero
state). Color is the existing warning token, not error - the failure
is recoverable (node may come back) and not a system-wide problem.

GlobalCommandPalette also calls useCrossNodeStackSearch but ignores
the third return value; its destructuring is additive-safe.

Resolves M-2 from the stack-management audit.
2026-05-24 15:40:25 -04:00
Anso 07a2e8f0e3 feat(stack-logs): WebSocket reconnect with backoff and gap sentinel (#1197)
The structured log viewer opened its WebSocket once and never tried to
recover. When the remote node dropped the tunnel mid-tail or the central
restarted, log output went silent with no indicator. The 10k row buffer
kept prior content on screen, so the silence looked like the stack had
just stopped producing logs.

Adds exponential-backoff reconnect (1s, 2s, 4s, 8s, 16s, cap 30s) with
a "reconnecting..." banner replacing the green "following" pip while
the socket is down. Backoff resets to the first delay after every
successful re-open.

docker logs -f has no resumable offset, so on successful reconnect the
viewer drops a synthetic warning sentinel into the row stream:

  --- reconnected; older lines may be missing ---

The sentinel renders at 70% opacity to distinguish it from container
output. Operators see at a glance that there was a gap.

The closedByCleanup flag prevents the reconnect loop from racing the
effect cleanup path: when the component unmounts or stackName changes,
the flag is set BEFORE ws.close() so the onclose handler short-circuits
instead of scheduling another reconnect.

Resolves M-1 from the stack-management audit.
2026-05-24 15:39:48 -04:00
Anso 82a4e94589 chore(stack-files): client-side path-traversal guard in stackFilesApi (#1190)
The backend already rejects path-traversal attempts through
isValidRelativeStackPath, so the server side is safe today. Adding a
client-side mirror is defense-in-depth: it shortens the failure loop
(no wasted round trip) and protects against a future server-side
regression that loosens validation.

Adds isClientSafeRelPath in frontend/src/lib/stackFilesApi.ts mirroring
the backend predicate (rejects absolute paths, drive letters,
backslashes, NUL bytes, double slashes, and any segment that is `.`
or `..`). Wraps every export that accepts a relPath / targetDir /
fromRel / toRel argument with assertSafeRelPath, throwing a clear
Error before the fetch is issued.

12 unit tests cover the predicate (POSIX accepts, traversal rejects,
Windows drive letters, backslashes, NUL bytes, non-string inputs).
Frontend suite stays at 288/288.
2026-05-24 15:39:27 -04:00
Anso 7c84969b31 fix(editor): harden save-deploy, node-switch, delete, and stats reactivity (#1188)
* fix(stacks): validate input, bound YAML parses, and reorder delete steps

Backend hardening covering three editor-served routes:

- `/:stackName/containers` GET adds an explicit `isValidStackName` guard so
  bad input is rejected at the call site even if the router-level param
  validator changes in future.
- `MAX_COMPOSE_PARSE_BYTES` (1 MiB) bounds the two `YAML.parse` callsites
  (`resolveAllEnvFilePaths`, `/services`) so a malformed or oversize compose
  cannot exhaust heap during routine env/service lookups.
- `DELETE /:stackName` is reordered to abort before any database cleanup
  if `FileSystemService.deleteStack` throws, keeping DB and FS in sync.
  Partial-failure responses now describe the resulting state in human
  terms instead of returning a generic 500.

Adds debug-mode entry-point traces (`[Stacks:debug] ...`) on save / down /
restart / delete handlers, all sanitised through `sanitizeForLog`. New
vitest covers the containers validator, the YAML size guard, and the
small-compose happy path.

* fix(editor): gate save-and-deploy on save success, abort stale loads

`saveFile` now returns a boolean: true on a successful PUT, false on any
failure. `handleSaveAndDeploy` short-circuits when save fails so a backend
500 on the compose write no longer slips through to a deploy with the
unsaved in-memory content. The diff-preview confirm path in ShellOverlays
applies the same guard.

`loadFile` now drives a per-hook `AbortController`. A stack switch, a
node switch (via `resetEditorState`), or hook unmount aborts the in-flight
GET chain so a late compose / env / containers / backup response from the
previous selection never overwrites freshly-loaded state.

`hasUnsavedChanges` is exported so EditorLayout can check it during the
node-switch lifecycle. New unit tests cover the boolean save contract and
the save-fail-blocks-deploy invariant.

* fix(editor): prompt on node switch when the editor has unsaved changes

Switching the active node previously called `resetEditorState()` without
checking the editor's dirty state, silently dropping in-progress edits.
The post-auth shell now intercepts the node-change effect: if the editor
is dirty, the attempted node is stashed via the existing
`pendingUnsavedNode` field, `pendingUnsavedLoad` is set to a sentinel
that routes `discardAndLoadPending` to `setActiveNode`, and `activeNode`
is reverted to the previous node so the dialog can be resolved without
losing content.

A re-entrant switch (clicking a third node while the dialog is still
open) is now ignored — the second switch reverts silently so the
dialog's anchor stays on the first attempt. When the previous node is no
longer in the registry and cannot be reverted to, the operator gets a
warning toast before the wipe so the loss is at least visible.

* fix(editor): split delete and deploy permission gates in the action bar

The action bar previously wrapped every affordance — including the Delete
menu item — in a single `can('stack:deploy')` check, even though the
backend route requires `stack:delete`. A user with `stack:deploy` only
saw a Delete button that 403'd, and a user with `stack:delete` only saw
no menu at all.

Each affordance now renders against its own permission: deploy / stop /
restart / update on `stack:deploy`, delete on `stack:delete`, rollback on
`canDeploy + isPaid + backupInfo.exists`, scan on `isAdmin +
trivy.available`. The overflow menu appears if any of {rollback, scan,
delete} is granted, so a delete-only operator still has a way to remove
the stack.

Adds a Monaco model dispose on EditorView unmount via the existing
editor ref, and a compact `Stats unavailable` chip in the CONTAINERS
header that lights up when the live-stats WebSocket reports a persistent
failure.

* fix(editor): make container-stats hook reactive to the active node

`useContainerStats` previously read the active node id from
`localStorage` on each WebSocket open, with a deps array of `[containers]`
only. After a node switch the stats stream stayed pointed at the
previous node's `/ws` endpoint until the containers array refreshed.

The hook now accepts `activeNodeId` as a second argument, depends on
`[containers, activeNodeId]`, and drops the localStorage read. The
return shape is `{ stats, error }`: the error field carries a string
when the stream fails, surfaced by EditorView as a small chip in the
CONTAINERS header. A per-WS `warnedOnce` set ensures a flaky daemon
emits at most one console.warn per stream lifetime, never at message
rate. Close codes 1000 / 1001 stay silent (normal teardown, navigation).

The error reset (`setError(null)`) is split into its own effect keyed on
`activeNodeId` so the banner does not flap on every containers-array
refresh tick against a persistently-flaky daemon. Tests cover the new
shape, the node-id reactivity, and the abnormal-close warn behaviour.

* docs(editor): describe new gate split and add troubleshooting entries

Updates the editor cockpit page to reflect that the action bar now
gates each affordance on its own permission (deploy / delete), and that
the bar appears for delete-only users so a stack can still be removed.

Adds three troubleshooting accordions covering the new behaviours: a
failed save that blocks the subsequent deploy, the unsaved-changes
prompt on node switch, and the live-stats chip when the daemon is
unreachable.

Adds an E2E spec verifying that a forced PUT 500 on the compose write
surfaces the failure toast and prevents the deploy POST from firing.

* fix(stacks): use printf-style format for compose-down warn

`console.warn` treats arg-1 as a printf format string when subsequent
args follow. The template literal here interpolated a sanitized but
not %-escaped stackName into arg-1 alongside an error argument, so a
stackName containing a `%s` placeholder could theoretically swallow
the error in the substitution. Switch to the file's established
`'... %s ...', value, err` pattern.

* test(editor): fix save-deploy spec; click both edit buttons, drop Monaco fill

The editor has two edit affordances: a lowercase 'edit' in the Anatomy
panel header that swaps the right column to the editor tabs, and a
capital 'Edit' in the editor toolbar that flips Monaco from read-only
into edit mode. The spec previously matched both with a case-insensitive
regex and only fired one click, so Monaco never entered edit mode.

It also tried to fill .monaco-editor textarea — that element is Monaco's
IME accessibility helper, hard-coded readonly; the real editable surface
is a contenteditable div.

`saveFile()` does not gate on a dirty buffer, so the spec does not need
to modify Monaco at all. Click both edit buttons with case-anchored
regexes and drop the fill step.

* test(editor): disambiguate Save & Deploy locator from sidebar row

The TEST_STACK fixture is named 'e2e-save-deploy-stack'. The sidebar
renders each stack into a div with role=button whose accessible name
includes the stack slug, so the regex /save.*deploy/i matches both the
sidebar row ('e2e-save-deploy-stack') and the editor toolbar's actual
Save & Deploy button — strict-mode bails. Anchor the locator to the
literal button text with exact:true.
2026-05-24 15:18:47 -04:00
Anso 60ecd574b3 fix(stacks): serialize concurrent lifecycle operations per stack (#1182)
* fix(stacks): serialize concurrent lifecycle operations per stack

Two simultaneous POSTs to /api/stacks/:name/{deploy,down,restart,stop,
start,update} could race against the same compose project, doubling
notifications, doubling post-deploy scans, and corrupting the
atomic-deploy backup snapshot. Each lifecycle route now acquires a
per-(nodeId, stackName) in-process lock; the second caller gets 409
with {code: 'stack_op_in_progress', inProgress: {action, startedAt,
user}} and the frontend surfaces a "X is already deploying" toast.

The lock is process-local on purpose: it shares a lifetime with the
docker compose child process. A Sencho restart clears all locks, which
matches the truth that an in-flight compose op is gone too.

The existing policy-block 409 is shape-distinguishable (has policy /
violations) and continues to work; the frontend checks the new code
discriminator first before falling through to policy handling.

* chore(stacks): validate action enum in 409 parser; cover start collision

The frontend parseStackOpInProgress used to cast the parsed action
directly to StackOpAction. A backend bug or spoofed payload returning
action='wibble' would slip through. Validate against the known enum
set before returning the parsed info.

Adds an integration test for the deploy-blocks-while-start-in-flight
case so all six lifecycle verbs have collision coverage (the existing
suite covered deploy/down/restart/stop/update; start was indirect).
2026-05-24 01:12:07 -04:00
Anso aa3d99a594 fix(mesh): re-evaluate data plane every 10s and add opt-in auto-recreate (#1184)
* fix(mesh): re-evaluate data plane every 10s and add opt-in auto-recreate

MeshService.dataPlaneStatus was written exactly once at boot in
setupMeshNetwork() and never re-evaluated. After the operator removed
sencho_mesh at runtime (or it was recreated externally, or Sencho was
disconnected from it), /api/health and the dashboard banner kept
returning the stale boot-time discriminator until the next process
restart.

Adds a 10s revalidator that inspects the current Docker truth in one
network-inspect call and transitions dataPlaneStatus to reflect it.
Short-circuits in not_started / not_in_docker / subnet_invalid
(states that cannot change within this process) and in concurrent
ticks. Transitions are idempotent on stable state, so the timer can
tick indefinitely on a healthy mesh without log noise.

New 'not_found' reason value for the network-was-removed-at-runtime
case. Existing reasons (subnet_mismatch, subnet_overlap, attach_failed)
also surface from the revalidator when their underlying conditions
arise post-boot. transitionDataPlane keeps message and subnet fields
fresh across consecutive observations even when reason is unchanged,
so /api/health never reports stale numbers (e.g. two consecutive
subnet_mismatch observations against different external subnets).

Adds an opt-in mesh_auto_recreate global setting (default off). When
on, the revalidator additionally calls attemptInPlaceRecreate() after
surfacing not_found. The helper hard-prefers the boot-chosen subnet
(this.meshSubnet) and never iterates candidates, because changing the
subnet here would invalidate every existing extra_hosts override on
disk. A real conflict on the original subnet is reported as
subnet_overlap and preserved during the 60s recreate throttle window
so the operator-actionable reason is not flapped back to not_found
between attempts.

Self-attachment is checked via Name match (operator --hostname X
matches container Name /X) or full container-ID prefix for hex
HOSTNAMEs >= 12 chars (Docker default short ID). Non-hex HOSTNAMEs
cannot collide with container IDs at all so a Name miss is conclusive;
short hex HOSTNAMEs preserve the prior status as 'unknown' rather than
risking a false-positive prefix match.

Frontend surfaces:
- types/mesh.ts: 'not_found' added to MeshDataPlaneReason.
- MeshDataPlaneBanner: 'not_found' headline copy.
- Settings > System > Mesh data plane: TogglePill bound to
  mesh_auto_recreate, default off, helper text explains the tradeoff.

Backend coverage in backend/src/__tests__/mesh-data-plane-revalidate.test.ts
(25 cases): short-circuits, idempotent stable-state, recovery from
subnet_mismatch / subnet_overlap, transition to not_found / subnet_mismatch
/ attach_failed, transient-Docker anti-flap, re-entrancy guard, name
match path, ID-prefix path, short hex hostname ambiguity, non-hex
hostname certainty, transition message refresh on observation drift,
auto-recreate off (default), auto-recreate success with senchoIp
preservation, auto-recreate overlap classification with no subnet
drift, throttle window preserves classified reason, throttle release.
Lifecycle test covers timer wiring in start()/stop().

Existing mesh-setup-error-classification suite (27 cases) still green.

Resolves: F-4 in the v1.0 audit tracker.

* fix(mesh): address Codex review of PR #1184

Three findings from the independent review:

BLOCKER: attemptInPlaceRecreate() called recordSetupFailure() on
create / attach failures, which clears this.senchoIp. The next
revalidator tick's attachment check is guarded on senchoIp, so with
it null the check is skipped and the snapshot path can silently flip
the status back to ok against a network where Sencho is in fact not
attached. Also: a later successful recreate would call
ensureSelfAttached() with senchoIp null, which short-circuits, so
the network gets recreated without binding Sencho.

Replaced the recordSetupFailure() calls in attemptInPlaceRecreate
with a new recordRecreateFailure() that uses transitionDataPlane and
preserves senchoIp. Added two tests: create-fails-then-succeeds
(verifies senchoIp survives the failure and the later retry binds
Sencho correctly) and create-succeeds-attach-fails-then-next-tick
(verifies the snapshot path surfaces attach_failed on the next tick
instead of falsely reporting ok).

SHOULD-FIX 1: single-key POST /api/settings wrote String(value)
without re-validating against the per-key schema, so an allowlisted
enum-shaped key like mesh_auto_recreate could persist arbitrary
strings ('banana', 'true') that the bulk PATCH would later refuse.
Routed the single-key path through SettingsPatchSchema.safeParse so
both write paths validate identically. Added regression tests for
an invalid mesh_auto_recreate value, a valid mesh_auto_recreate
write, and an out-of-range numeric value.

SHOULD-FIX 2: the new Mesh data plane subsection lived inside a
section the registry exposes to non-admins, who would see the toggle
and only learn it was admin-only after the save 403'd. Gated the
subsection on `isAdmin` from useAuth so non-admins do not see the
control. The other system controls keep their existing visibility
pattern (read-only for non-admins).

71/71 backend tests green (revalidate + mesh-setup + settings-routes).
276/276 frontend tests green. tsc clean on backend + frontend.
2026-05-23 18:09:39 -04:00
Anso bb44db0cb1 refactor(sidebar): rebuild footer as priority-driven Ops Pulse strip (#1178)
* refactor(sidebar): rebuild footer as priority-driven Ops Pulse strip

Replace the simple notification ticker with a derived activity summary that
picks one of six states (active-op, failure, automation, recent-event,
quiet-live, disconnected) and routes per-state clicks to logs, schedules,
or activity. The hook owns the cascade; the component is pure presentation;
EditorLayout owns wiring.

Failure detection covers unread errors in the last 24h; recent-event is
limited to non-error stack notifications in the last hour; automation reads
the next /scheduled-tasks?action=update run and a debounced state-invalidate
listener; the deploy-panel composite key is used for elapsed-time tracking
so close-then-immediately-reopen counts as a new session.

* refactor(sidebar): apply Ops Pulse audit fixes

- countEnabledAutoUpdates now defaults missing autoUpdateSettings entries to
  enabled, matching the backend's getStackAutoUpdateSettingsForNode contract.
  Previously the automation state could not render even with the documented
  per-row default-true.
- findFailure now requires a stack_name so the sidebar does not select a
  system-level error whose click would no-op through navigateToNotification.
  System errors continue to surface via the top-bar NotificationPanel.
- DeployPanelState gains a monotonic sessionId sourced from the existing
  internal counter, and the new usePanelSessionStartedAt hook keys the
  elapsed-time tracker off it so a same-stack rerun always resets even when
  isOpen stays true across succeeded then preparing.
- buildConfig splits quiet-live out of the default and adds an exhaustiveness
  guard so future SidebarActivitySummary variants fail to compile.
- New unit tests cover the default-true aggregation, the same-stack session
  reset, the non-stack failure guard, and the useNextAutoUpdateRun debounce
  and cleanup paths. Frontend suite: 276 / 276 pass.
2026-05-23 15:43:06 -04:00
Anso a9282671d5 fix(webhooks): hide write affordances from non-admin paid users (#1176)
* fix(webhooks): hide write affordances from non-admin paid users

Backend gates POST/PUT/DELETE /api/webhooks with `requireAdmin`, but
WebhooksSection rendered the Create webhook button, the New-webhook form,
the per-row toggle, and the delete button for any paid user. A non-admin
operator clicked through to a 403 error toast.

WebhooksSection now reads `isAdmin` from AuthContext and unmounts those
four affordances when the operator is not admin. Non-admins still see the
list, trigger URLs, masked secrets, and execution history per the docs
promise at docs/features/webhooks.mdx:7. A read-only On/Off chip stands in
for the toggle so the enabled state stays visible.

A useEffect resets `showForm` whenever `isAdmin` flips back to false, so
the form cannot remain open across a role downgrade.

* fix(webhooks): correct settings entry description for incoming webhooks

The Webhooks entry in the settings registry described the sibling
notification-routing feature: "Outbound HTTP hooks to Slack, Discord,
Teams, or custom endpoints" with keywords for those channels. The actual
page configures incoming HMAC-signed triggers that run stack actions from
CI/CD pipelines.

Update the description to match the real feature and replace the keywords
so settings search resolves "trigger", "ci", "hmac", and "incoming" to the
Webhooks page (and stops resolving "slack"/"discord" there).
2026-05-23 15:42:31 -04:00
Anso fcff8e9047 fix(monitor): collapse repeated host-metric alerts into per-window summary (F-11) (#1175)
* fix(monitor): collapse repeated host-metric alerts into per-window summary (F-11)

A host metric over threshold previously dispatched one notification every 5
minutes for the duration of the breach, producing 7+ identical messages
in 35 minutes and spamming Discord/Slack routes. Replace the hardcoded
5-minute cooldown for CPU/RAM/disk with a per-metric suppression window
(default 60 min, configurable via host_alert_suppression_mins). The first
breach fires immediately; subsequent cycles within the window are silently
counted; the next dispatch after the window elapses carries a summary
suffix listing how many cycles were suppressed and when the breach first
crossed threshold. Recovery clears the counter so re-breach fires fresh.

The pattern mirrors PolicyEnforcement.notifyTrivyMissingOnce: module-scope
Map, in-memory only, in-cycle dedup, with a test-reset helper. The
existing system_state row keeps post-restart re-fires bounded.

Janitor and per-stack alert rules are unchanged; they already have
adequate cadence and per-rule cooldown respectively.

* fix(ci): restore backend and frontend checks

* fix(e2e): remove create button timing race

* fix(e2e): harden create double-click test

* fix(monitor): clear persisted F-11 timestamp on recovery + clamp suppression window

Independent audit on the previous commit surfaced two issues.

1. clearHostMetricSuppression early-returned on missing in-memory state,
   leaving a stale system_state.last_host_*_alert_ts row alive after a
   process restart. Scenario: breach fires + persists timestamp, process
   restarts, metric recovers before another evaluate cycle re-seeds the
   in-memory Map, recovery cleanup early-returns. Next re-breach inside
   the original window hits the restart-survivability branch and is
   silently suppressed instead of firing fresh. Fix: read persisted
   state in clearHostMetricSuppression and reset to '0' independently
   of in-memory presence. The read-before-write also skips redundant
   writes when the row is already cleared.

2. host_alert_suppression_mins is validated by zod on the bulk PATCH
   path but the single-key POST /api/settings path accepts allowlisted
   keys without re-validation. A 999999999-minute value would silence
   host alerts for centuries. Add MAX_HOST_ALERT_SUPPRESSION_MIN = 1440
   mirroring the zod max, and clamp via Math.min in evaluateGlobalSettings.

Two new vitest cases (restart-then-recovery-then-rebreach; the 1440
clamp) confirmed failing before the fix, passing after. The existing
"metric drop" case updated to use a mock-backed persistence pattern
consistent with the new restart-scenario tests. 73/73 monitor-service
tests green; full backend suite 2507/2510 (same pre-existing Windows
EBUSY flake on filesystem-backup.test.ts as baseline).
2026-05-23 06:28:01 -04:00
Anso e46f6980f8 ci(frontend): shim storage in test setup
Add a Vitest setup storage shim for localStorage and sessionStorage when Node/jsdom does not provide usable storage.

This fixes the Node 26 frontend CI failures where tests accessed localStorage during setup.
2026-05-23 06:05:54 -04:00
Anso 9d8d8abcba fix(stacks): close dialog and toast on Empty create (F-2) (#1168)
* fix(stacks): close dialog and toast on Empty create (F-2)

The Empty branch of the Create Stack dialog left no visual confirmation
on a successful POST: no success toast, no busy state on the Create
button, and no double-click guard. The first thing a new operator does
is create a stack, so a click that looks like a no-op is the day-one
impression killer.

What changed
- Empty handler now mirrors the Git and docker-run patterns: a
  creatingEmpty busy guard, Loader2 spinner on the Create button,
  disabled Cancel mid-flight, and a "Stack <name> created." success
  toast fired synchronously before the editor-load handoff.
- The Empty panel is wrapped in a form so the Enter key submits the
  same path as clicking Create.
- Mapped 403 to a clearer permission message; fall back to the
  backend's error string for other non-OK statuses.
- Capture the active node id at handler entry and pass it through
  onStackCreated. The parent compares against the live node ref and
  skips the editor load if the user switched nodes mid-create, with
  an info toast pointing at the prior node.
- Tightened the create-a-new-stack E2E (no more silent .catch on the
  dialog-close assertion, asserts the toast, no longer relies on a
  page reload to see the sidebar entry) and added a regression spec
  that the busy guard collapses double-clicks into a single POST.

* fix(stacks): close double-click race in create handler (F-2)

The setState-based busy guard could race a second click that landed
before React committed the disabled state, so two POSTs got dispatched
when a user double-clicked Create. The new useRef-based check is
read and written synchronously inside the handler so a re-entrant
invocation bails before issuing a second fetch.

The accompanying E2E (`create dialog: double-clicking Create fires
only one POST`) was also hanging to its 30s test timeout: the second
click's locator resolution could outlive the dialog when the first
POST resolved quickly, so Playwright waited for `[role="dialog"]` to
reappear. Both clicks now fire in the same microtask via Promise.all
with a 1s timeout on the second click so the locator-resolution path
fails fast instead of hanging the test.

* fix(stacks): keep busy guard active across modal close (F-2)

Two issues from independent review.

1. Important: resetting creatingEmpty / creatingEmptyRef inside the modal
   close handler created an Escape/backdrop race. A user could close the
   dialog mid-flight (clearing both flags), reopen, click Create again,
   and slip past the guard before the first POST settled. The finally
   block already owns that lifecycle, so the close handler no longer
   touches either flag.

2. Nit: bring 403 messaging to parity with the Git branch. The Empty
   handler now reads the backend's error field first and falls back to
   the original hardcoded copy if it is missing, mirroring how
   handleCreateStackFromGit surfaces backend permission errors.

* test(stacks): rebuild double-click spec around observable disabled-state (F-2)

The Promise.all racing approach to "double-click fires only one POST"
hung to the 30s test timeout on CI. The page snapshot at timeout confirms
the product fix works correctly (dialog closed, stack created, editor
navigated); the test itself was the flake. Race-based assertions on a
single React-state commit boundary are inherently timing-sensitive.

The rewrite turns the test inside-out:

- The route handler holds POST responses for 400ms so the in-flight
  window is large enough to observe deterministically.
- await route.continue() (instead of void fire-and-forget) closes a
  subtle stall pattern Playwright's docs flag under load.
- After the first click the test asserts the button reaches a disabled
  state within the in-flight window, which proves the busy guard
  activated at the React-state layer.
- A best-effort force-click against the now-disabled button exercises
  the user-mash path; the synchronous useRef guard inside the handler
  catches it whether or not the browser dispatches the click.
- expect(postCount).toBe(1) still owns the actual regression assertion.

No product code change. The synchronous creatingEmptyRef guard already
landed in 61e79861.
2026-05-23 03:04:53 -04:00
Anso e4fe4cfced fix(mesh): address Codex audit findings on F-1 PR (#1158)
- docs(sencho-mesh): split subnet_overlap troubleshooting into env-set
  vs env-unset paths; rewrite the "Customising the mesh subnet" intro
  to describe the candidate list and the adopt-existing behavior.
- backend(MeshService): preserve idempotent 409 handling in the
  explicit-env path. On createNetwork 409 (TOCTOU race against another
  process), re-inspect and treat the race-winner as success when its
  subnet matches the operator's request; subnet_mismatch otherwise.
- frontend(MeshDataPlaneBanner): trim the card variant to a true
  one-line strip (headline only, truncate min-w-0). Full recovery
  hint stays on the Routing tab variant and in docs.
- tests(mesh): add five cases covering the previously untested
  branches — candidate-loop non-overlap bail, adopt-existing with
  unparseable subnet, explicit-env generic createNetwork failure,
  TOCTOU 409 race-winner match, TOCTOU 409 race-winner mismatch.

Architecture map (gitignored per Directive 11) updated locally with
the new useMeshDataPlane hook node and the mesh.dashboardBanner flow
so the local interactive viewer stays accurate.
2026-05-22 13:39:12 -04:00