Commit Graph

598 Commits

Author SHA1 Message Date
Anso 29c599d370 fix(stacks): return a handled response when saving env to a stack with no env file (#1393)
PUT /api/stacks/:stackName/env resolved no env path when a stack had a
compose file but no .env on disk. It then wrote to an undefined path,
which threw and surfaced as an opaque 500. Guard the missing-env case
and return a clean 404 so direct API callers get an actionable response
instead of a crash. The editor still only edits an existing env file, so
behavior in the UI is unchanged.
2026-06-19 00:44:36 -04:00
Anso 2821e87d3f fix: gate fleet stop-by-label on a resolved preview and validate remote stop responses (#1392)
* fix: gate fleet stop-by-label on a resolved preview and validate remote stop responses

The destructive "Stop fleet by label" action could run before its blast
radius was known, and a malformed remote response could be rendered as a
successful zero-stack stop. Both are release blockers for the fleet
stop-by-label flow.

Gate the "Stop fleet" button on a resolved blast radius: the live
match-preview resolving to at least one matching stack, or a dry run of the
current label when the preview endpoint is unavailable. Carry the resolved
node and stack list into the confirm modal so the operator confirms against
the concrete targets rather than a label name alone. Editing the label
invalidates a prior dry-run snapshot, so a stale blast radius cannot
re-enable the action.

Validate the remote local-stop 200 body before trusting it. A body that is
not the local-stop contract (missing matched flag, non-array results, or a
malformed result element) now fails that node with a clear error, consistent
with the non-ok and unreachable paths, instead of defaulting matched to true
and results to empty.

* fix: ignore stale fleet stop-by-label preview responses after the label changes

The debounced match-preview callback set the preview state unconditionally,
so a request issued for one label that resolved after the operator switched
to another label would mark the preview ready with the old label's blast
radius. That re-enabled the destructive Stop and showed stale nodes/stacks
in the confirm modal for a label that no longer matched them.

Guard the in-flight request with a per-run cancelled flag flipped in the
effect cleanup, so a response for a label that is no longer current cannot
set the preview. This mirrors the cancellation pattern already used by the
suggestions effect. Add a test that holds a preview request in-flight,
switches the label, then resolves the stale response and asserts Stop stays
disabled and the old targets do not leak.
2026-06-18 23:29:20 -04:00
Anso ba09e6f69e fix: base Git multi-file Compose deploy env and dossier on the effective config (#1391)
* fix: resolve the root .env at deploy and render time for Git context-dir stacks

A Git multi-file source with a context dir set --project-directory to that
dir, so Docker Compose looked for .env there and missed the root .env Sencho
writes. Validation already passed the root .env with --env-file, so a stack
could validate with one effective config but deploy or render another.

Add authoredComposeEnvFileArgs, which appends --env-file <stackDir>/.env when
the applied deploy spec has a context dir and a root .env exists, and wire it
into the deploy/update, image-scan, render, and container-listing compose
invocations so they all resolve env from the same file the validator used. A
non-ENOENT access error surfaces instead of silently dropping the flag.

* fix: base multi-file Git dossier and doc-drift on the effective Compose model

The Stack Dossier and its documentation-drift check parsed only the stored root
compose file. For a multi-file Git source, services, ports, networks, or volumes
that an override file adds were invisible, so the dossier showed incomplete facts
and doc-drift could falsely warn that a documented port is unpublished when an
override actually publishes it.

Add a secret-safe GET /stacks/:name/effective-anatomy that renders the merged
effective model and extracts only structural facts (services, ports, volumes,
networks, restart), never env, label, or command values. StackAnatomyPanel
fetches it for multi-file Git stacks and feeds those facts into the dossier and
doc-drift, falling back to the root-only parse for single-file or non-git stacks
and whenever the render is unavailable.

* fix: add an inline path-injection barrier to the Git env-file resolver

CodeQL js/path-injection flagged the fs.access in authoredComposeEnvFileArgs
because the env path derives from the route-supplied stack name and the only
containment check lived in the callers, not at the sink. Resolve the stack dir
against the compose base and assert containment with startsWith inline, then
derive the .env path from the validated dir, mirroring the existing inline guards
in renderConfig and validateCompose. Valid stack names are unaffected; a name
that escapes the base now yields no --env-file.

* test: stabilize the dossier doc-drift e2e against the dossier-load race

The first assertion filled the access_urls field as soon as the Dossier panel
was visible, but the panel's GET /stacks/:name/dossier resolves by overwriting
the fields from the server (empty access_urls) and only then flips the doc-drift
gate on. When the GET landed after the fill, it clobbered the typed value and the
warning never rendered, so the test failed intermittently under CI timing. Wait
for that GET to land before typing, mirroring the spec's openStack helper.
2026-06-18 18:25:58 -04:00
Anso 5f1baa7522 fix: harden deploy/update concurrency and node-targeting safety (#1390)
* fix: harden deploy/update concurrency and node-targeting safety

Release stabilization for deploy/update operational safety.

Per-stack operation locking is now global. Background lifecycle paths
(scheduler auto stop/down/start/backup/update, webhook execute, Git source
auto-deploy, image auto-update, label bulk actions, fleet snapshot redeploy,
and mesh redeploy) acquire the per-node, per-stack lock through a new
StackOpLockService.runExclusive helper and skip rather than race a manual
deploy/update/rollback/backup on the same stack and node. Skips surface
honestly (a failed scheduled run, a recorded webhook failure, a per-stack
batch result, or a thrown error) instead of a silent no-op.

Update readiness and policy-bypass now run against the node captured when the
dialog opened, not the live active node, so switching nodes while a dialog is
open cannot retarget the update or the bypass retry.

Rollback readiness no longer presents a moving-tag or unpinned image as a ready
image revert. Restoring files does not revert a moving tag, so those stacks
read as partial, and the rollback success message states that the compose and
env files were restored.

* fix: lock blueprint reconcile against manual ops and correct rollback wording

Follow-up to the deploy/update safety hardening, closing two more gaps from a
verification pass.

BlueprintService.deployLocal and withdrawLocal called ComposeService directly,
so blueprint reconciliation could race a manual deploy/update/rollback/backup on
an owned stack. Both now run their compose lifecycle call through
StackOpLockService.runExclusive and skip (recorded as a failed reconcile,
retried on the next cycle) on conflict. The withdraw holds the lock across both
the compose down and the directory delete so neither races a manual operation.

The runtime rollback messages overstated recovery: a rollback restores the
compose and env files and recreates containers, but does not revert an image
behind a moving tag. The auto-rollback deploy-progress output, the recovery
panel and chip, the failure toasts, and the manual rollback route message now
state that the compose and env files were restored, with the matching OpenAPI
example and atomic-deployments doc updated.

* fix: acquire stack lock before blueprint deploy mutates compose and marker files

Local blueprint deploy wrote the compose and marker files and ran the policy
assert before acquiring the per-stack lock; the lock only wrapped the deploy
itself. A reconcile could therefore rewrite an owned stack's files while a
manual deploy/update/rollback/backup was running. The lock now wraps the whole
critical section (create, write compose, write marker, policy assert, deploy),
so on conflict nothing is written and the reconcile records a failed outcome.

Adds a test asserting a deploy under a held lock records failed, writes no
marker file, and leaves the manual lock untouched.

* fix: make remote blueprint apply atomic under the receiving node's stack lock

Remote blueprint deploy wrote the compose and marker files to the target node
via separate HTTP calls and only locked on the final deploy, so the file writes
could race a manual operation on that node. A node's operation lock is
process-local and cannot be held by the hub across HTTP calls, so the locked
create/write/deploy now runs on the receiving node.

The locked critical section is extracted into BlueprintService.applyLocalUnderLock
and exposed via POST /api/blueprints/apply-local. The hub posts the blueprint to
that endpoint in one call; the receiving node runs create + write compose+marker
+ deploy under its own per-stack lock. Older nodes without the route answer 404
and fall back to the legacy multi-call flow. The endpoint is gated by paid tier
and the same per-stack stack:edit and stack:deploy permissions as the
PUT-compose + deploy it bundles, validates the stack name, compose size, and
marker structure, and returns 409 on a lock conflict without writing anything.

Adds tests for the atomic single-call path, the 404 legacy fallback, the 409
lock-conflict mapping, the route validation and permission paths, and the
write-compose-then-marker-then-deploy ordering of the shared locked apply.

* fix(deps): bump undici to 7.28.0 to clear high-severity advisory

The frontend CI npm audit gate (--audit-level=high) failed on a transitive
undici 7.25.0 (a dev-only dependency via jsdom): TLS certificate validation
bypass (GHSA-vmh5-mc38-953g) and cross-user cache information disclosure
(GHSA-pr7r-676h-xcf6). Bumping undici within jsdom's existing ^7.25.0 range to
7.28.0 clears the high-severity advisory and unblocks the frontend job. Lockfile
only; no direct dependency or source change.
2026-06-18 13:38:19 -04:00
dependabot[bot] c0f84cb04b chore(deps): bump the all-npm-backend group in /backend with 11 updates (#1387)
Bumps the all-npm-backend group in /backend with 11 updates:

| Package | From | To |
| --- | --- | --- |
| [axios](https://github.com/axios/axios) | `1.17.0` | `1.18.0` |
| [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) | `12.10.0` | `12.11.1` |
| [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware) | `4.1.0` | `4.1.1` |
| [isomorphic-git](https://github.com/isomorphic-git/isomorphic-git) | `1.38.4` | `1.38.5` |
| [multer](https://github.com/expressjs/multer) | `2.1.1` | `2.2.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.2` | `25.9.3` |
| [eslint](https://github.com/eslint/eslint) | `10.4.1` | `10.5.0` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.61.0` | `8.61.1` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.8` | `4.1.9` |
| [@aws-sdk/client-ecr](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ecr) | `3.1065.0` | `3.1070.0` |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1065.0` | `3.1070.0` |


Updates `axios` from 1.17.0 to 1.18.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.17.0...v1.18.0)

Updates `better-sqlite3` from 12.10.0 to 12.11.1
- [Release notes](https://github.com/WiseLibs/better-sqlite3/releases)
- [Commits](https://github.com/WiseLibs/better-sqlite3/compare/v12.10.0...v12.11.1)

Updates `http-proxy-middleware` from 4.1.0 to 4.1.1
- [Release notes](https://github.com/chimurai/http-proxy-middleware/releases)
- [Changelog](https://github.com/chimurai/http-proxy-middleware/blob/master/CHANGELOG.md)
- [Commits](https://github.com/chimurai/http-proxy-middleware/compare/v4.1.0...v4.1.1)

Updates `isomorphic-git` from 1.38.4 to 1.38.5
- [Release notes](https://github.com/isomorphic-git/isomorphic-git/releases)
- [Commits](https://github.com/isomorphic-git/isomorphic-git/compare/v1.38.4...v1.38.5)

Updates `multer` from 2.1.1 to 2.2.0
- [Release notes](https://github.com/expressjs/multer/releases)
- [Changelog](https://github.com/expressjs/multer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expressjs/multer/compare/v2.1.1...v2.2.0)

Updates `@types/node` from 25.9.2 to 25.9.3
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint` from 10.4.1 to 10.5.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.4.1...v10.5.0)

Updates `typescript-eslint` from 8.61.0 to 8.61.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.61.1/packages/typescript-eslint)

Updates `vitest` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/vitest)

Updates `@aws-sdk/client-ecr` from 3.1065.0 to 3.1070.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ecr/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1070.0/clients/client-ecr)

Updates `@aws-sdk/client-s3` from 3.1065.0 to 3.1070.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1070.0/clients/client-s3)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.18.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: better-sqlite3
  dependency-version: 12.11.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: http-proxy-middleware
  dependency-version: 4.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: isomorphic-git
  dependency-version: 1.38.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: multer
  dependency-version: 2.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: "@types/node"
  dependency-version: 25.9.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: eslint
  dependency-version: 10.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: typescript-eslint
  dependency-version: 8.61.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: vitest
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: "@aws-sdk/client-ecr"
  dependency-version: 3.1070.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1070.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 13:26:06 -04:00
Anso cb58cc423f fix(fleet): resolve Stop-by-label stack labels across all nodes (#1382)
* fix(fleet): resolve Stop-by-label stack labels across all nodes

The Fleet Actions "Stop by label" card only ever saw the control node's own
stack labels. The stack-label routes are proxied, so each node stores its
labels in its own database and the control holds no mirror for remote nodes.
The suggestions, match-preview, and fleet-stop endpoints all read that
nonexistent mirror, so remote-only labels were invisible and remote stacks
were skipped before the remote was ever asked.

Make all three authoritative across the fleet with a new
collectFleetLabelSummaries helper: the local node reads its own database,
and each remote is queried live through its labels and label-assignments
endpoints over the proxy, with fail-closed parsing and per-node
reachability. fleet-stop drops the mirror pre-check and always calls each
reachable remote's local-stop receiver, reporting unreachable nodes at the
node level so they never block the reachable ones.

The picker now surfaces remote-only labels, aggregates shared names once
with combined counts and the carrying node names, and flags incomplete
coverage when a node is unreachable. The preview groups matches per node,
lists unreachable nodes separately, and distinguishes no matching stacks
from "label exists but no stacks" from "remote unavailable".

* fix(fleet): harden Stop-by-label card against malformed responses

Guard the match-preview and fleet-stop response bodies so a malformed but
200 reply degrades instead of crashing or misreporting. match-preview now
validates the per-node shape before rendering (the preview reads it outside
any try/catch) and logs a malformed body; fleet-stop distinguishes a
non-array results body (a server bug, now logged and surfaced as an
unexpected-response error) from a genuine empty fleet, and guards per-node
stackResults.

Docs: an unreachable or errored remote is reported once per node, not as a
per-stack error row.
2026-06-17 13:25:12 -04:00
Anso f23b7e1bac feat: ordered multi-file Compose for Git sources (#1380)
* feat: ordered multi-file Compose for Git sources

Extend Git sources to deploy an ordered list of compose files merged with
docker compose -f base.yaml -f override.yaml ..., plus an optional project
directory.

- Pick and reorder compose files from the repository tree (drag to reorder on
  desktop, up/down arrows on phones); manual path entry is also supported.
- The ordered set drives every stack-scoped compose command (deploy, update,
  start/stop/restart/down, image scans, Compose Doctor) and the container
  lookup, so a service or image declared only in an override is handled too.
- Runtime keys off the materialized set, not the saved configuration: saving a
  source does not change deploy args until the pull is applied, and apply
  materializes from the pending snapshot rather than live config.
- The project directory is passed as --project-directory, with -p <stack>
  pinning the Compose project so container labels stay stable.
- The Mesh override is layered last; single-file sources are byte-identical to
  before, and existing rows keep working via the single-path fallback.

Docs cover the picker, ordering, project directory, and the new troubleshooting
and limitations (referenced files are not materialized; the dependency graph,
drift, and networking views read the primary file).

* fix: harden multi-file Git source (hash, unlink, collisions, node id)

- hashContent folds ordered file CONTENTS (not paths) so a clean multi-file
  stack is not flagged as locally edited: create/apply hash the fetched files
  (repo paths) while pull hashes the on-disk files (materialized paths), which
  previously disagreed and showed a false "local edits detected".
- Block unlinking a multi-file or project-directory Git source (409): the deploy
  spec lives on the source row, so removing it would silently revert deploys to
  root compose.yaml. Single-file sources still unlink.
- Reject materialized-path collisions in the selection validator: an additional
  file equal to or nested under compose.yaml, an ancestor/descendant overlap
  between selected files, and a project directory nested under a compose file
  (previously a 500 at materialization).
- DockerController.getContainersByStack uses the controller's node compose dir
  and passes its node id to the authored prefix, instead of the process default.

* fix: CI failures on multi-file Git source (test crash, aria query, path barrier)

- GitSourceFields no longer crashes when repoUrl/branch are falsy: the canBrowse
  trim() is optional-chained, so a reusable field component tolerates partial
  props. Fixes the apply-binding panel test, which feeds a minimal source object.
- GitSourcePanel tests query the footer Remove button by its exact name, so the
  picker's per-file "Remove <path>" buttons no longer collide with the broad
  /remove/i match (the test intent, footer Remove present/absent, is unchanged).
- validateCompose uses an inline resolve + startsWith barrier at the context-dir
  mkdir sink (CodeQL does not credit the wrapped isPathWithinBase helper),
  clearing the js/path-injection alert. The containment check is equivalent and
  contextDir is also validated upstream.

* test: update Git source E2E spec for the multi-file compose picker

The compose-file picker replaced the single #git-source-path input and added
per-file Remove buttons, so the E2E spec drove selectors that no longer exist:

- Drop the redundant compose.yaml fills (the picker defaults to compose.yaml).
- Select the footer Remove button by exact name so the picker's per-file
  "Remove <path>" buttons no longer make the locator ambiguous.
- Set a custom compose path through the picker (add via the manual input, press
  Enter, then remove the default compose.yaml).

* test: match the footer Remove button with an exact Playwright name

Playwright's getByRole name option is a substring match by default, so
{ name: 'Remove' } also matched the picker's "Remove <path>" buttons. Require an
exact match so only the footer Remove button is selected.
2026-06-17 13:24:55 -04:00
Anso 7ce045accb feat: pre-deploy scan visibility and pinned scanner version (#1378)
* feat: pre-deploy scan visibility and pinned scanner version

Pin managed Trivy installs and add an opt-in pre-deploy scan advisory so a
manual deploy can surface each image's latest scan before it runs.

- Managed Trivy now installs a pinned, known-good version by default for
  reproducible installs. Auto-update still tracks the latest release, and an
  explicit update always pulls the latest.
- Add an opt-in pre-deploy scan advisory: when enabled, deploying a stack from
  the editor first shows each image's latest cached scan severity for review.
  It is visibility only and never blocks; deploy enforcement is unchanged.
- Backend: pre_deploy_scan_advisory setting, PUT
  /security/pre-deploy-scan-advisory, a cache-only GET
  /security/stacks/:name/pre-deploy-summary, and a node-scoped
  getLatestVulnScanByDigestForNode lookup.
- Frontend: advisory toggle on the Security page scanner setup, and a
  PreDeployScanDialog wired into the editor deploy flow that fails open when the
  summary is unavailable.
- Docs: scanner configuration, version pinning, and the advisory.

* fix: harden pre-deploy advisory guard, toggle visibility, and installer busy state

Addresses review findings on the pre-deploy advisory.

- Block a second editor deploy during the async advisory window with a
  synchronous pending ref, cleared on cancel and in the deploy's finally, so a
  double-click can no longer start two deploys.
- Keep the pre-deploy advisory toggle visible to admins whenever the setting is
  on, so it can still be turned off after the scanner becomes unavailable.
- Resolve the managed Trivy version inside the install lock so the busy state and
  serialization cover the latest-version fetch and the managed-install check.
2026-06-16 00:42:33 -04:00
Anso 770bead889 fix(deps): bump form-data, protobufjs, and vite to clear high-severity CVEs (#1379)
The npm audit and Trivy image-scan CI gates fail on newly disclosed
high-severity advisories in transitive backend dependencies. Bump the
locked versions to their fixed releases:

- form-data 4.0.5 -> 4.0.6 (via axios; CVE-2026-12143, CRLF injection)
- protobufjs 7.5.8 -> 7.6.4 (via dockerode/@grpc; CVE-2026-48712, Any-expansion DoS)
- vite 8.0.5 -> 8.0.16 (vitest dev dependency; fs.deny bypass and launch-editor)

Lockfile-only change (npm audit fix, no package.json edits). Backend build,
lint, and the route/integration tests stay green, and backend
npm audit --audit-level=high now reports 0 vulnerabilities.
2026-06-15 20:33:03 -04:00
Anso 058cf8f2c7 feat: make image-update check cadence configurable and visible (#1377)
* feat: make image-update check cadence configurable and visible

The background image-update scanner polled registries on a hardcoded
6-hour interval, with no way to see when it last ran or when the next
run was due. Operators testing updates read this as auto-update being
unreliable: a manual update checks the registry immediately and applies,
so the slow background scan rarely raised the "update available"
notification before the stack was already current.

Backend:
- ImageUpdateService reads image_update_check_interval_minutes (15-1440,
  default 120) and drives a single generation-guarded self-rescheduling
  timer with 10% per-run jitter so fleet nodes do not poll in lockstep.
  restartPolling() applies a new interval live, with no restart, and
  cannot leave a duplicate timer when a save lands mid-scan.
- GET /api/image-updates/status now returns checking, intervalMinutes,
  lastCheckedAt, nextCheckAt, and the manual-cooldown fields. New
  admin-only PUT /api/image-updates/interval persists the setting and
  reschedules.

Frontend:
- New Settings > Automation > Image update checks section to choose the
  interval (read-only for non-admins; admin enforced on the backend).
- The Auto-Update readiness view shows last-checked, next-check, and a
  ticking manual-recheck cooldown, and the copy distinguishes registry
  detection from scheduled auto-update execution.

Adds backend unit and route tests and frontend component tests, and
updates the auto-update documentation.

* fix: drop stale image-update status response in the readiness strip

loadCadence() ran on mount and again after a Recheck with no request
token, so a slow initial /image-updates/status response could resolve
after the recheck-triggered one and overwrite the fresh cooldown with
stale data, or set state after the view unmounted. Guard setCadence with
a monotonic token mirroring loadReadiness, and bump it on unmount. Adds a
regression test for the out-of-order resolution.
2026-06-15 20:06:13 -04:00
Anso 888f658a7a feat(files): move files and folders across directories in the stack explorer (#1373)
* feat(files): move files and folders across directories in the stack explorer

Add a cross-directory move to the stack file explorer. Files and folders can
be relocated either through a "Move to..." context-menu item that opens a
folder-picker dialog, or by dragging an entry onto a folder node (or onto the
root area to move it to the stack root).

The backend reuses the existing rename endpoint: renameStackPath now resolves
both ends through the leaf helper, so a symlink moves as the link entry rather
than its target, and it guards against moving a directory into its own subtree.
A cross-filesystem rename surfaces as a clean 409 instead of a 500. Protected
root files (compose / docker-compose / .env) stay put. Moving the open file, or
a folder containing it, deselects the viewer; a move that would discard unsaved
edits is blocked with a clear message.

* fix(files): fold case in move guards and keep the move dialog open on failure

Harden the cross-directory move against case-insensitive filesystems and fix a
dialog dismissal edge:

- Protected root files (compose / docker-compose / .env) were gated by an exact,
  lowercase name match. On a case-insensitive filesystem a request like
  COMPOSE.YAML resolves to the real compose.yaml and slipped past the gate, so a
  protected file could be moved out of the stack root via the API. The gate now
  folds case on case-insensitive platforms; Linux stays case-sensitive, where a
  differently-cased name is a distinct, unprotected file.
- The directory-into-descendant guard compared resolved paths case-sensitively,
  so a source supplied with non-disk casing skipped the guard and fell through to
  an opaque OS error (500) instead of a clean 400. The comparison now folds case
  the same way.
- The move dialog closed after awaiting the move regardless of outcome, so a
  blocked move (unsaved edits) or a failed move dismissed the picker as if it had
  succeeded. The shared handler now reports success and the dialog only closes on
  an actual move.
2026-06-14 21:56:06 -04:00
Anso 4610a433e6 fix(fleet): scope Stop-by-label to stack labels with a typed suggestion source (#1368)
* fix(fleet): scope Stop-by-label to stack labels with a typed suggestion source

The Fleet Actions "Stop by label" card labelled its target field generically
as "Label", so a same-named node label could look like a valid stop target in
a destructive workflow. The action has always matched stack labels only, but
nothing in the copy or the data flow made that explicit.

Add a stack-label-only suggestions endpoint and make the scope unmistakable:

- New GET /api/fleet/labels/suggestions aggregates the per-node stack labels
  into a name-keyed list with stack and node counts (admin-only, central DB,
  covers every configured node including offline remotes). Node labels are
  never folded in.
- The card now sources its autocomplete from that endpoint and renders each
  suggestion with its stack and node counts via a typed FleetStopLabelSuggestion
  model, so node-label data cannot be fed into this destructive card.
- Copy is explicit throughout: "Stack label" target field with a helper line
  that node labels are not used, a clear "0 matching stacks" readout and a
  "No stacks are assigned to this stack label" empty preview, and confirm and
  result copy that references stacks and the stack label.
- Docs updated (fleet-actions, stack-labels) and tests added on both sides,
  including node-only exclusion, name collision, multi-node counts, the
  zero-stack preview, and the non-fatal suggestions-load path.

* docs: correct stale Stop-by-label button and helper references

The Stop-by-label walkthrough referenced a "Stop matching stacks" button and a
warning callout that no longer exist on the card. Align the docs with the live
card: the primary action is "Stop fleet", and the scope is stated by the helper
line under the input.
2026-06-12 22:14:04 -04:00
Anso ef5a3f00a7 feat: add an on-demand node-wide security scan with live progress (#1367)
Add a "Scan this node" action on the Security overview that scans, in one pass,
any combination of three types: image vulnerabilities, image secrets, and
compose misconfigurations. Progress streams live into the deploy-feedback modal.

- TrivyService.scanNode runs the selected scanners across the node's images and,
  for misconfig, every stack's compose file, behind a per-node lock and tolerant
  of per-item failures. The existing scanAllNodeImages becomes a thin vuln-only
  wrapper over the shared image loop, so scheduled scans are unchanged.
- POST /api/security/scan-node (admin, scanner-gated) streams sanitized progress
  to the deploy terminal and returns a combined summary. Secret scans stream
  counts only, never matched values.
- Frontend adds a "scan" action verb and a ScanNodeLauncher wired into the
  overview; the scan stays bound to the node it started on even if the active
  node changes mid-run.
2026-06-12 19:07:45 -04:00
Anso 3d39d856a3 feat: chart-led Security overview with sortable Images and History tables (#1364)
* feat: chart-led Security overview with sortable Images and History tables

Refine the Security page around the existing design system and add the
data the dashboard needs.

- Overview leads with four charts (30-day risk trend, severity donut, top
  exposed images, findings by type); the signal-rail counts become a
  secondary summary, and the scanner and deploy-enforcement posture follow.
- Images becomes a recessed table with search, a severity filter, sortable
  columns, a last-scan column, and inline scan actions; the findings cell is
  clickable into the scan sheet, and the per-row cursor tooltip is dropped
  where the columns already carry that information.
- Policies puts deploy-enforcement first, collapses the policy packs into an
  accordion, and uses the standard primary button for Add policy.
- Suppressions and acknowledgements move their titles and Add buttons outside
  the cards, matching the Fleet tab layout.
- History switches from the detail sheet to an inline table (search, sortable
  columns, two-scan compare, pagination); the now-unreachable scan-history
  overlay is removed.
- Add GET /api/security/overview/trend, a node-scoped daily critical/high
  rollup backing the risk-trend chart.
- Extract the shared image-scan hook and the severity classifier, and harden
  the overview data fetch so a malformed non-critical response can never read
  as a clean security state.

* fix: treat malformed Security responses as errors, not empty or clean states

Address an independent review of the data-fetch paths so a 200 with an
unexpected shape can never read as a benign "no findings" view.

- SecurityView: validate that the image-summaries body is a scan-summary map; an
  unexpected shape now sets the error state instead of an empty map. Isolate the
  trend fetch in its own self-catching promise so a transport failure on the
  non-critical chart can no longer poison the overview or summaries error state.
- useImageScan: only a "completed" poll counts as success (a malformed or unknown
  status now throws), and a failed post-scan summaries refresh is logged instead
  of silently dropped.
- HistoryTab: a 200 whose body lacks an items array is treated as an error, not
  an empty "no completed scans" list.
2026-06-12 14:35:03 -04:00
Anso 2a4955f56d feat: add dedicated Security page and policy-pack foundation (#1362)
* feat: add dedicated Security page and policy-pack foundation

Bring vulnerability scanning, scan history, suppressions, Compose risks,
secrets, policy packs, and scanner setup into one node-scoped Security
command center instead of scattering them across Resources and Settings.

- New top-level Security view with Overview, Images, Compose risks,
  Secrets, Policies, Suppressions, History, and Scanner setup tabs
  (status masthead + signal rail; controlled tabs with deep-link support).
- Backend: GET /security/overview rollup and GET /security/policy-packs
  static catalog (auth-only, Community). DatabaseService gains an uncapped
  scan-status count and a node-eligible block-policy count, and
  getImageScanSummaries now projects secret and misconfig counts.
- Reuse existing surfaces: the scan-history sheet, the control-governed
  suppression and acknowledgement panels, and the scan-detail sheet (now
  with an initial-tab prop so it opens on the matching finding type).
- Extract a shared SeverityBadge (from Resources) and a TrivyManager
  (from Settings) so both surfaces render identical controls.
- Resources "Scan history" now links into the Security page History tab.
- Docs for the new Security surface and tests for the new endpoints,
  helpers, nav wiring, and tabs.

* refactor: consolidate scanner and policy management onto the Security page

Remove the Settings "Vulnerability Scanning" section now that the Security
page covers the same ground, with every option preserved:

- Scanner install / update / uninstall / auto-update live on the Scanner setup
  tab (TrivyManager).
- Scan policies, the honor-suppressions toggle, and the replica
  managed-by-control / demote controls move into a new ScanPolicyManager on the
  Policies tab (paid; Community sees only the policy-pack catalog).
- CVE suppressions and acknowledgements remain on the Suppressions tab.

Wiring removed: the registry section and the now-empty Security settings group,
the SectionId, the SettingsSectionContent case and the isPaid prop it was the
sole consumer of, and SecuritySection itself. The dashboard configuration-status
"Vulnerability scanning" row now navigates to the Security page Policies tab.

Docs that pointed at "Settings -> Security -> Vulnerability Scanning" are swept
to the relevant Security page tabs.

* fix: harden Security page scanner refresh, policy-load errors, and secret-only badges

Address independent-review findings on the Security page:

- Scanner setup now refreshes Trivy state when the active node changes, so the
  displayed scanner status matches the node TrivyManager's actions target (both
  follow x-node-id). Previously, switching nodes on the tab left stale state.
- ScanPolicyManager surfaces an explicit error state on a failed policy fetch
  instead of falling through to a false "No scan policies configured".
- The shared SeverityBadge and the Images findings column no longer label a scan
  "clean" when it has secrets or misconfigurations but no CVE severity
  (highest_severity is derived from vulnerabilities only); they show a "Findings"
  state and the secret/misconfig counts instead.
- The Overview enforcement note points to the Policies tab, not the removed
  Settings section.
- The History tab auto-opens the scan-history sheet only on a deep-link (mount
  with the History tab active), not on every manual tab selection.

Adds tests for the badge secret/misconfig state and the policy-load error state.
2026-06-12 10:41:39 -04:00
Anso 77f1611971 feat: Compose Network Inspector and exposure intent guard (#1360)
* feat: add Compose Network Inspector facts engine

Render a stack's authored effective model and pair it with the live
Docker snapshot to derive per-stack networking facts: project networks
with external and internal flags, service-to-network membership and
aliases, published ports with host-binding scope, network_mode, and
extra_hosts, plus runtime drift (runtime-only attachments, foreign
networks, and declared-but-unused or missing networks).

Extend the effective-model parser with service network membership,
extra_hosts, and label keys (key names only, never values), and add a
key-space normalized network model with adapters from both the rendered
model and the raw declared compose so the Inspector and drift share one
comparison. Expose GET /api/stacks/:stackName/networking: advisory and
read-only, it renders the authored model only and never returns or logs
raw stderr, env values, or label values.

* feat: store and edit per-stack and per-service exposure intent

Add a stack_exposure_intent table (intent values constrained by a CHECK,
unique per node, stack, and service) with DAO methods to read, upsert,
clear one row, and clear all rows for a stack. The classification is
stored independently of the generated networking facts so a later
mismatch stays detectable; service rows are kept separately from the
stack-level row (service '').

Expose GET and PUT /api/stacks/:stackName/exposure: GET requires read
access, PUT requires edit access and validates the intent against the
allowed set. Sending intent null clears that row, returning the scope to
unset so a service inherits the stack intent again. Intent rows are
cleared when the stack is deleted and when the owning node is removed,
so a later same-named stack never picks up stale classification.

* feat: add exposure-aware Compose Doctor findings

Feed the Compose Doctor's effective-model context with the stored
exposure intent (resolved into a stack-level value plus per-service
overrides) and the dossier's documented access-URL ports, read fail-soft
so a metadata read error skips these checks rather than failing the
preflight. Add five deterministic findings on top of that context:

- a service classified internal or same-node that publishes a host port
  (same-node tolerates a loopback bind),
- a sensitive database or admin image published on all interfaces,
- a port-publishing stack with no exposure intent set,
- a published port not reflected in the documented access URLs,
- reverse-proxy labels with no documented URL or reverse-proxy intent.

The rules stay pure functions over the preflight context; the registry
completeness test pins the new rule set.

* feat: detect compose network drift in the drift ledger

Extend the spatial drift engine with two network-level findings: a
running container attached to a stack-owned or foreign network that
compose does not declare (one finding per service), and a declared
network that no running service uses or that is absent from the runtime
(one stack-level finding, every network named by its resolved runtime
name). The comparison reuses the same helper the Network Inspector uses,
so the two surfaces never disagree.

Network drift runs only when the stack has running containers and the
runtime is reachable, preserving the existing missing-runtime,
parse-error, and unreachable behavior. The findings persist through the
existing drift ledger and surface on the Drift tab, which now labels the
two new kinds.

* feat: link a Docker network back to its owning stack

Add a cross-component open-stack event and make the owning-stack badge on
a managed network in Resources a link: clicking it loads that stack on
its node and opens the editor, reusing the existing fleet navigation. A
latest-ref keeps the window listener current without re-subscribing each
render. Image and volume badges are unchanged; only a managed network
opts in via the new optional handler.

* feat: add the Networking tab to the stack detail panel

Add a capability-gated Networking tab that reads the per-stack networking
facts and exposure intent. It shows the project networks (with external,
internal, and created-by-stack flags), per-service network membership and
aliases, published ports with their host-binding scope, network_mode and
extra_hosts, and runtime drift, degrading to the declared model when the
runtime is unavailable. Users can classify the stack and each service
(internal, LAN, reverse proxy, public, and so on) or clear a row to
inherit; the controls are read-only when the user cannot edit, and a
broken exposure response never tears down the facts view.

A new compose-networking capability is added to both registries so older
nodes hide the tab, and the tab cross-links to the Doctor for the deploy
and security findings.

* docs: document the Compose Networking tab

Add a feature page covering the Networking tab: the network facts,
published ports and host bindings, the exposure-intent classification
and inheritance, the exposure-aware Doctor findings, runtime drift, and
a troubleshooting section. Register it in the docs navigation next to
Compose Doctor.

* feat: add a redacted network summary to the Stack Dossier export

Append a network exposure section to the dossier Markdown: the stack and
per-service exposure intents, the networks with their external and
internal flags, and each service's published ports with their binding
scope. It carries only names, intents, port numbers, and scope, never an
env value or a label value.

The summary is fetched only when the user exports (copy or download), so
opening the panel costs nothing, and it degrades to omitting the section
when the data is unavailable. The whole-fleet dossier export collects the
same summary per stack, rethrowing the unauthorized sentinel like the
sibling loaders.

* feat: add a Fleet networking filter for exposure and drift

Add a per-node networking summary that classifies a node's stacks as
exposed (a host port published beyond loopback), unknown-exposure
(publishes ports with no exposure intent set), or network-drift. It
reads each stack's compose with the light dependency parser and one
Docker snapshot, so it stays cheap across a node's full stack set, and
it skips drift when the runtime is unreachable rather than inventing it.

Serve it node-locally at GET /api/networking/summary, and aggregate it
fleet-wide at GET /api/fleet/networking-summary: the hub computes its own
summary in-process and reaches each remote through its node-local route,
degrading an unreachable or older node to a skip. Because the aggregate
lives under the proxy-exempt /api/fleet prefix it is never wrongly
proxied. The Fleet overview gains a networking filter chip backed by that
aggregate, fetched fail-soft and detached so it never gates the grid.

* fix: spin the Networking refresh button while it reloads

The refresh button silently refetched the same data, so a click gave no
feedback. Track a refreshing state and spin the icon while the load is in
flight, disabling the button, matching the Compose Doctor preflight
button.

* fix: apply effective per-service exposure intent to unclassified checks

The "unclassified exposure" decisions only consulted the stack-level intent
row, so a service classified directly (with no stack row) was still reported
as unclassified, and a service explicitly marked unknown over a classified
stack was missed.

Both the exposure-unclassified preflight rule and the networking summary's
unknown-exposure bucket now resolve the effective intent per publishing
service (service row overrides stack row), matching the precedence already
used by the exposure-internal-published rule.

* fix: resolve drift network names via the compose top-level name

When a compose file sets a top-level name:, Docker prefixes resource names
with that project name instead of the stack directory. The light dependency
parser dropped name:, so network-drift normalization compared runtime
networks against directory-prefixed names and reported false
network-undeclared / network-missing findings.

Carry the parsed project name through DeclaredCompose and use it when
normalizing declared networks for drift, while still filtering containers by
the stack directory.
2026-06-12 02:15:11 -04:00
Anso f253276303 fix: make published port links open reliably (#1359)
* fix: make published port links open reliably

Container published-port links now render as real anchors that open on
desktop and mobile, replacing ad hoc window.open calls. A shared service
URL builder centralizes host resolution (configured host, remote node
API host, or the browser host, with no browser fallback for unreachable
remote nodes), protocol selection (HTTPS for port 443), and known app
sub-paths (Plex opens its web path). The container port mapping itself is
the link, with a Copy URL action beside it. The stack Open App menu and
the anatomy panel footer use the same builder, and the menu only offers
Open App when a reachable URL can be built.

* fix: skip UDP ports and scope known-app paths to the container port

Two follow-ups to the published-port links:

- The known-app path (Plex web sub-path) was borrowed from the published
  host port even when the container port was known and unregistered, so a
  non-Plex service published on host port 32400 wrongly inherited it. The
  container-port lookup now wins when known; the published-port lookup stays
  a fallback for the menu and anatomy footer, which only have the host port.

- UDP ports could surface as HTTP links. The backend now carries the port
  protocol through both container-mapping paths and skips UDP when choosing
  the main web port (extracted as selectMainWebPort), and the container card
  filters UDP before selecting a port to link.
2026-06-11 16:37:53 -04:00
dependabot[bot] a2fe58f62f chore(deps): bump @grpc/grpc-js (#1356)
Bumps the all-npm-backend-security group with 1 update in the /backend directory: [@grpc/grpc-js](https://github.com/grpc/grpc-node).


Updates `@grpc/grpc-js` from 1.14.3 to 1.14.4
- [Release notes](https://github.com/grpc/grpc-node/releases)
- [Commits](https://github.com/grpc/grpc-node/compare/@grpc/grpc-js@1.14.3...@grpc/grpc-js@1.14.4)

---
updated-dependencies:
- dependency-name: "@grpc/grpc-js"
  dependency-version: 1.14.4
  dependency-type: indirect
  dependency-group: all-npm-backend-security
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-11 14:02:40 -04:00
Anso 38aabe7064 feat: health-gated updates and rollback readiness (#1354)
* feat: classify stack deploy and update failures with suggested next actions

Failed deploy and update responses now carry a failure classification
(cause category, headline, and suggested next step) derived from the
compose error output. The recovery panel and chip render the
classification and include it in copied diagnostics, and gateway-style
failures surface as a node-unreachable cause.

* feat: add update and rollback readiness reports for stacks

Before a manual update, Sencho now shows an advisory readiness verdict
computed from the stored preflight result, open drift findings, live
container health, the pending image change, the rollback backup slot,
and node disk headroom. The Stack Dossier gains a rollback readiness
section that states what a rollback can restore and explicitly
discloses that volume and bind-mounted data are not covered. Toolbar
and sidebar updates now share one update path, and admins can create a
fleet snapshot from the readiness dialog before updating. Nodes that do
not advertise the capability keep the direct update flow.

* feat: observe stack health after updates with a post-deploy health gate

After a deploy or update succeeds, Sencho now watches the stack for a
configurable observation window and records a passed, failed, or
unknown verdict: containers must stay running, healthchecks must report
healthy, and restart loops or disappearing containers fail the gate.
The deploy panel shows the observation live and holds off auto-closing
until the verdict lands, a failed gate surfaces the existing recovery
actions including rollback, and the stack timeline records update
started and gate verdict events. Scheduled, webhook, bulk, and
git-source updates are gated the same way; rollbacks and installs are
deliberately not. The gate is observational only and can be tuned or
disabled per node under host alert settings.

* docs: document health-gated updates and rollback readiness

New operator page covering the update readiness dialog, the post-update
health gate and its settings, the rollback readiness disclosure, and
classified failures, with cross-links from the atomic deployments and
deploy progress pages. The API reference gains the readiness and
health-gate endpoints, the healthGateId success field, and the failure
classification schema on deploy and update error responses.

* feat: withhold the success verdict while the health gate observes

An update used to show a green Succeeded that a failed health gate then
contradicted moments later. The deploy modal now reports Verifying
health while the gate observes, shows success only when the gate
passes, and makes a failed or unknown gate the headline result; success
toasts soften to a verifying message while a gate runs. The mobile
recovery card groups its actions behind one bottom-right Take action
menu so it stays compact on a phone, with the classified cause still
visible on the card. A successful image update now also counts as the
last known-good marker in rollback readiness, and the docs gain
screenshots of the readiness dialog, gate states, dossier section, and
settings.

* fix: harden log format strings and the env existence path check

Log calls that interpolated the stack name into the console format
string now use constant format strings with placeholder arguments, and
envExists validates path containment inline at its filesystem access,
matching the established patterns used elsewhere in the same files.

* test: adapt deploy modal success specs to the post-deploy health gate

The deploy feedback modal now withholds its success verdict while the
health gate observes the new containers, showing "Verifying health"
until the gate passes. The two success-path E2E tests waited for
"Succeeded" within the gate's 90s default window and timed out.

Shorten the observation window to the 15s minimum for these tests via
the settings API, assert the verify-then-succeed sequence the modal
actually renders, and restore the default window afterward so the test
value does not leak into later runs.

* fix: serialize health gate polling and harden gate observation

Address race conditions in the post-update health gate found in review.

Backend: the gate poller used setInterval, so a Docker observe slower
than the 5s tick could overlap the next poll and corrupt the restart and
missing-container accounting, and a wedged socket could leave a poll
pending forever. Polling is now single-flight: each cycle self-schedules
the next only after it settles, and the observe is bounded by an 8s
timeout so a hung probe counts as a poll error and resolves the gate
unknown after three in a row.

Frontend: the gate poller could overlap requests, letting a slow earlier
"observing" response overwrite an already-applied terminal verdict. It is
now single-flight with a terminal latch, so a late response can never
roll the UI back from passed or failed.

Also reject a non-digit nodeId on the snapshot coverage route instead of
letting parseInt coerce it, document that turning off the deploy progress
panel opts out of the live gate UI while the gate still runs server-side,
and add gate-coverage tests for the webhook, git source, and auto-update
apply paths plus the new single-flight, observe-timeout, and recovery
cases.
2026-06-11 00:26:26 -04:00
Anso 60d138ac33 chore: raise dev auth rate limit so the full E2E suite is not blocked (#1353)
The Playwright E2E suite logs in per test (fresh context each) and has grown past the 100-attempt/15-min dev cap on the auth limiter, so the last test in a run gets 'Too many attempts' and fails. Raise the non-production cap to 1000 to restore headroom for the suite and local tooling. Production stays at 5 attempts/15min, so brute-force protection is unchanged.
2026-06-10 12:54:23 -04:00
dependabot[bot] c0b83d8326 chore(deps): bump the all-npm-backend group in /backend with 6 updates (#1351)
---
updated-dependencies:
- dependency-name: http-proxy-middleware
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: semver
  dependency-version: 7.8.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: "@types/node"
  dependency-version: 25.9.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: typescript-eslint
  dependency-version: 8.61.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: "@aws-sdk/client-ecr"
  dependency-version: 3.1065.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1065.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 12:47:15 -04:00
Anso 52ff0725f4 feat: add Compose Doctor preflight checks for stacks (#1348)
* feat: add Compose Doctor preflight checks for stacks

Add an on-demand, advisory preflight that renders a stack's effective
Compose model with `docker compose config` and runs a registry of
deterministic checks before deploy, surfacing findings grouped by
severity (blocker, high, warning, info) with a remediation for each.
Findings cover unset env vars, host-port conflicts on the node, broad
0.0.0.0 exposure, missing bind-mount paths, a mounted Docker socket,
privileged and host networking, moving image tags, missing restart
policy and healthcheck, Swarm-only deploy fields, missing external
networks or volumes, and container_name collisions.

The report is node-scoped and stored as the last run per stack, and the
route auto-proxies to the active node so a remote stack is checked on
the node that owns it. A new Doctor tab on the stack detail panel runs
preflight and shows the grouped findings, with a severity dot on the tab
when the last run has blocker or high findings. The tab is gated on a
compose-doctor capability so older nodes hide it.

No environment value is ever stored, returned, or logged: only env key
names and structural facts are read, and render failures surface a
generic message or the missing required-variable names, never raw
stderr.

* fix: scroll the stack tab strip when its tabs overflow

Adding the Doctor tab can push the per-stack Anatomy tab strip past the
panel width on narrower layouts. Make the tab row scroll horizontally
with subtle edge fades that appear only while there is more to scroll in
that direction, so a panel wide enough to show every tab is unchanged.

* fix: add clickable arrows and wheel scroll to the stack tab strip

Hiding the scrollbar left mouse users with no way to scroll the
overflowing tab row: a vertical wheel does not move a horizontal overflow
and native rows do not drag-scroll. Replace the passive edge fades with
clickable chevron arrows shown only when the row overflows that edge, and
translate a vertical wheel over the row into horizontal scroll.

* fix: inline the path-injection barrier in renderConfig

CodeQL's path-injection check does not credit the wrapped isPathWithinBase
helper as a sanitizer, so move the containment check inline at the spawn
cwd sink, matching the canonical barrier used elsewhere in the codebase.
Behavior is unchanged: the resolved stack directory must be contained in
the compose base and may not be the base itself.

* fix: hoist the compose-config spawn into the path-barrier scope

The earlier inline barrier sat in a different scope than the spawn cwd
sink (separated by the Promise-executor closure) and used a compound
guard, so CodeQL did not credit it. Use the exact canonical startsWith
barrier and hoist the spawn into the same scope as the check. Behavior
is unchanged: the executor runs synchronously in the same tick as the
spawn, so handlers still attach before any event can fire.
2026-06-10 11:35:39 -04:00
Anso d369b03a38 feat: detect stalled stack updates and add in-app recovery actions (#1347)
* feat: detect stalled stack updates and add in-app recovery actions

Add a backend idle-output backstop that stops a deploy/update compose step
that has gone silent (SENCHO_COMPOSE_STALL_TIMEOUT_MS, default 10m), so a
hung image pull surfaces a fast failure instead of spinning indefinitely.

Surface failed, timed-out, and stalled operations with recovery actions on
the stack page: a desktop chip plus popover menu and an inline mobile card
offering retry, restart, roll back (when a backup exists), refresh state,
and copy diagnostics, all gated by deploy permission. The streaming
deploy/update progress modal is now on by default and warns when output
goes quiet. Container state is refreshed after a failed or stalled
operation, and the UI never sits in an indefinite spinner.

* fix: harden rollback against policy-blocked file mutation and refine recovery

Address review findings on the stalled-update recovery work:

- The rollback route restored backup files before running the policy gate, so
  a policy-blocked rollback could leave the on-disk config rolled back while the
  deployed containers were unchanged. Snapshot the current files first and
  revert them when the gate blocks; if that revert itself fails, escalate it on
  the persistent alert feed since the 409 is already sent.
- Refresh container state after a successful manual rollback (rollback
  redeploys), without mis-recording a refetch failure as a rollback failure.
- Suppress the stalled-output warning once live progress is unavailable.

* test: mock snapshotStackFiles in the atomic-deploy rollback route tests

The rollback route now snapshots stack files before restoring a backup, so its
FileSystemService mock needs snapshotStackFiles. Without it the mocked call
threw and the route returned 500, failing the success-path rollback assertions.
2026-06-10 10:12:24 -04:00
Anso e7895c889d fix: base Stack health uptime on container start, not creation (#1341)
The dashboard Stack health UP column counted from each container's
Created timestamp, which never moves on stop/start or restart, so a
restarted container kept reporting its original age. Resolve uptime from
State.StartedAt (via a briefly cached inspect with bounded concurrency,
falling back to Created when inspect is unavailable) so it reflects the
real time since last start.

The current CPU and MEM columns separately summed the latest sample per
container with no recency filter, letting a recently stopped container's
final reading linger in the totals. Drop samples that trail the freshest
sample by more than the stale window so stopped containers leave the sum.
2026-06-09 20:16:04 -04:00
Anso 2298f470bd feat: allow local Docker Hub, GHCR, and custom registry credentials on Community (#1338)
Private registry credentials are no longer paid-only. Community admins can add
and manage Docker Hub, GHCR, and custom/self-hosted registry credentials, stored
locally on the node. AWS ECR (short-lived token refresh, AWS region) stays on the
paid tier.

Backend gates ECR per-type via a single helper applied at create, update (using
the effective type so an existing row cannot be switched to ECR), the per-id
connection test (gated on the stored type), and the stateless test. The admin
role and API-token-scope rejection are unchanged on every route. The frontend
drops the blanket paywall on the Registries section, filters ECR out of the type
selector for Community, and states the ECR requirement inline.
2026-06-08 08:59:27 -04:00
Anso ea1267b32f feat: give Community a 14-day in-app audit log (#1337)
* feat: give Community a 14-day in-app audit log

The audit-log list view is now reachable on the Community tier, scoped to a
rolling 14-day recent-activity window, with the existing filters (actor,
method, search, date range). Full retention, CSV/JSON export, and per-row
anomaly annotation remain on the paid tier.

Backend clamps the list window for unpaid tiers and forces anomaly annotation
off; a non-numeric from query value is normalized so it cannot lift the clamp.
The stats and export endpoints keep their paid gate. The frontend hides the
export control and the anomaly stat tiles for Community and omits the anomaly
request. Audit entries are still captured for every tier, so only read access
changes.

* test: repoint distributed-license stand-in to a still-paid audit route

The distributed-license trust-chain test borrowed GET /api/audit-log as a
paid-gated, DB-reading stand-in. That route is now Community-accessible, so its
six 403 PAID_REQUIRED assertions flipped to 200. Point the stand-in at
GET /api/audit-log/stats, which keeps requirePaid plus the system:audit
permission gate and is satisfied by both token types the test uses.
2026-06-08 08:58:49 -04:00
Anso 54119be0c2 feat: move trivy auto-update, node labels, fleet topology, and single-scan SBOM to Community (#1336)
Rebalance several capabilities from the paid tier to the free Community tier:

- Managed Trivy auto-update toggle is admin-only, no longer tier-gated.
- Node labels (assign, view, manage) are available on every tier; cordon and
  FleetSync anchor reset stay paid.
- Fleet topology layout modes (Hub, Grouped, Free) are available on Community.
- Single-scan SBOM export (SPDX and CycloneDX) is admin-only on Community;
  SARIF export stays paid via a dedicated canExportSarif capability split out
  from the former shared SBOM flag.

Backend route guards and frontend affordances are updated together, with tier
and admin-role tests covering the Community-allowed and still-paid paths.
2026-06-08 08:58:14 -04:00
Anso 710647a44f feat(snapshots): preserve stack dossiers with fleet snapshots (#1339)
* feat(snapshots): preserve stack dossiers with fleet snapshots

Fleet snapshots can now optionally capture each stack's Dossier notes
alongside its compose and .env files, so a recovery restores the
operational knowledge around a stack, not just its configuration.

- Opt-in global setting "snapshot_documentation" (default off), toggled
  from the renamed Fleet settings section.
- Capture reads local dossiers from the database and remote dossiers over
  the Distributed API proxy; only stacks with notes are recorded, and
  secret values are never included.
- Captured notes are stored encrypted at rest in a new fleet_snapshots
  column and surfaced in the snapshot detail view behind a badge.
- Cloud and downloaded archives gain a documentation.json (archive_version 2).
- Restore stays conservative: dossier notes are written back only when the
  operator explicitly opts in, on both single-stack and restore-all paths.
- Existing snapshots and archives remain valid; behavior is unchanged when
  the setting is off.

* fix(snapshots): harden dossier-notes restore against bad input and partial failures

Address review findings on the documentation-snapshots restore path:

- Parse `restoreNotes` strictly (=== true) on single-stack restore, matching
  restore-all, so a stray non-boolean can never opt in to overwriting notes.
- Guard findSnapshotDossier: require an array of stacks and real dossier
  content, so a malformed or all-blank entry can't clobber current notes.
- Make the dossier-notes write non-fatal relative to the file restore: a notes
  failure (e.g. a remote dossier PUT) is caught, reported via `notesError`, and
  no longer 500s the single restore or fails the stack in restore-all once the
  files are already written.
- Surface the partial outcome in the UI: a warning toast on single restore, a
  summary note on restore-all, and gate the "Documentation captured" badge and
  restore-all notes control on captured stacks while rendering capture warnings.

Adds tests for strict parsing, malformed/blank blobs, remote notes restore
(success + non-fatal failure, single and bulk), and scheduled capture-on.

* fix(snapshots): drop unused binding in restore-all remote notes test

The restore-all remote notes test destructured a node id it never uses
(restore-all is driven by snapshot id alone), tripping no-unused-vars and
failing the lint step. Bind only the snapshot id.
2026-06-08 08:44:59 -04:00
Anso b21324f97a feat(stacks): persist a drift ledger with temporal source-change detection (#1333)
* feat(stacks): persist a drift ledger with temporal source-change detection

Build on the read-only compose-vs-runtime drift check so a stack's drift is
remembered over time, not just shown at a glance.

- Record a deploy baseline: on a successful deploy, update, or rollback, store
  the deployed compose file's source and rendered-model hashes on the stack so
  the Drift tab can tell whether the file has changed since the last deploy.
- Surface temporal drift in the Drift tab: "matches last deploy", "source
  changed since last deploy" (distinguishing a model change from a
  formatting-only edit), or "no deploy baseline yet".
- Persist findings into a drift ledger: a re-check reconciles the current
  findings, recording newly detected ones and resolving cleared ones, and shows
  a short drift history under the findings. The drift report read stays
  side-effect-free; only an explicit re-check (and a deploy) writes the ledger.
- Write drift detected/resolved events to the stack Activity timeline so the
  provenance sits alongside deploys and restarts.

Node-local and available on the Community tier. Reconciliation is skipped when
a check is not authoritative (Docker unreachable or a compose parse error) so an
open finding is never falsely cleared.

* fix(stacks): record the drift baseline for every deploy path and harden the ledger

Address review feedback on the drift ledger:

- Record the deploy baseline in ComposeService.deployStack/updateStack instead of
  only the manual route, so bulk, Git-source, App Store, scheduler, and webhook
  deploys all capture source/rendered hashes. Reconciliation stays on the explicit
  re-check.
- Store no rendered baseline when the local parser cannot model the compose (for
  example a file over the parse cap) rather than a sentinel that would make a later
  real change read as unchanged.
- Let temporal-overlay failures surface as a 500 instead of being hidden behind a
  neutral "no baseline"; only the compose read stays best-effort.
- Omit the temporal card entirely when a report (for example from an older remote
  node) carries no temporal data, instead of showing a misleading "no baseline".
- Keep drift_detected / drift_resolved history-only by excluding them from the
  routable-category whitelist, so they are never offered as a channel route that
  would never fire.
- Use a JSON separator for the finding identity key so the source file is plain
  text (no embedded control byte).

* fix(stacks): sanitize logged errors in the drift report handlers

The drift report and re-check handlers logged the caught error object
raw alongside the stack name, which a code scan flagged as a
log-injection vector: a crafted stack name surfacing inside an error
message or stack could forge log lines. Route the error through the log
sanitizer so control characters are stripped before writing. Render it
with util.inspect first so the stack trace, cause chain, and underlying
error codes are preserved for debugging.
2026-06-07 20:44:22 -04:00
Anso 421177e4a6 feat(stacks): add compose-vs-runtime drift detection (#1329)
* feat(stacks): add compose-vs-runtime drift engine

Add a read-only engine that compares a stack's on-disk compose model
against the live Docker runtime and reports where the two diverge.

GET /api/stacks/:stackName/drift returns a per-stack report with a
status (in-sync, drifted, missing-runtime, unreachable) and typed,
service-scoped findings: a declared service with no running container,
a running container not declared in compose, an image mismatch, and a
published-port mismatch. The report is computed at request time with no
persistence and is available on every tier.

The check reuses the existing compose parser and Docker dependency
snapshot; the compose parser now also captures each service's declared
image. Boundaries fail closed: an unreadable compose file reports
drifted and an unreachable Docker daemon reports unreachable, never a
false in-sync.

* fix(stacks): keep drift hasContainers accurate on compose parse error

assembleStackDrift hardcoded hasContainers: false on the parse-error
path, contradicting the field's contract when the runtime actually has
running containers. Compute it once from the container set and reuse it
across all return paths.

Also add a route test that exercises the successful 200 path for an
existing stack on the Community tier (stubbing only the Docker boundary),
so a tier gate or handler regression after the existence check is caught.

* feat(stacks): add a drift detection tab to the stack view

Surface the compose-vs-runtime drift report on the per-stack Anatomy
panel as a read-only Drift tab. It shows the stack's status (in sync,
drifted, not running, unreachable) and, when drifted, the specific
service-scoped reasons with the declared and running values side by
side. A re-check action reruns the comparison.

The tab lives in the shared anatomy panel, so it appears on both the
desktop stack view and the mobile stack detail. Available on every tier.

* fix(stacks): sanitize the logged error in the drift report builder

The compose-read and Docker-snapshot catch blocks logged the raw error
object, whose message can embed the user-controlled stack path (e.g. an
ENOENT path). Log the error through the existing sanitizer so a crafted
stack name cannot forge log lines, matching the pattern used elsewhere
in the stacks router.
2026-06-07 15:48:04 -04:00
Anso 57fe430db8 feat(stacks): add Stack Dossier tab with operator notes and Markdown export (#1326)
* feat(stacks): add Stack Dossier tab with operator notes and Markdown export

Add a Dossier tab beside Anatomy and Activity on the stack detail panel. It
shows a read-only summary auto-derived from the stack's Compose anatomy
(services, ports, volumes, network, restart policy, env file, source) plus an
editable form for the context Sencho cannot infer: purpose, owner, access URLs,
static IP, VLAN, and firewall, reverse-proxy, backup, upgrade, recovery, and
custom notes.

Notes persist per stack and per node in a new stack_dossiers table, reached
transparently through the remote-node proxy so a remote stack's dossier
round-trips to the node that owns it. Reading a dossier needs stack read
permission; saving needs stack edit. The tab exports a single Markdown document
combining the generated facts and the operator notes, with copy-to-clipboard and
download actions; env values are never exported, only variable names and counts.

Available on all tiers. The standalone anatomy copy-as-Markdown shortcut is
removed since the Dossier export supersedes it.

* fix(stacks): gate dossier reads/writes on stack existence; clear dossier on node delete

A dossier endpoint validated the stack name but not that the stack exists, so an
editor could PUT a dossier for a name with no stack, leaving an orphan row that a
later stack of the same name would inherit. Require the stack to exist (existing
requireStackExists guard) on the dossier GET and PUT, returning 404 otherwise.

Also clear a node's stack_dossiers rows when the node is deleted, alongside the
other node-scoped cleanup, so removing a node leaves no orphan dossiers.

Docs: scope the "no secret exported" statement to the generated facts (which only
ever carry variable names and counts) and clarify that operator notes are
exported exactly as written.
2026-06-06 22:21:30 -04:00
Anso af4083175c feat(fleet): add read-only dependency map tab (#1324)
* feat(fleet): add read-only dependency map tab

Add a fleet-wide Dependencies tab to Fleet view that maps how stacks,
services, networks, volumes, and ports relate, with flags for missing
dependencies, port conflicts, orphaned resources, and cross-stack shared
resources. Read-only; filterable by stack, node, and flag; collapsed by
default with a list-view fallback at scale.

The graph is derived at request time from Docker and compose metadata, so
no new table or persisted state is introduced. A per-node graph endpoint
feeds a hub aggregation endpoint that fans out across the fleet and
degrades gracefully, surfacing unreachable or unparseable nodes inline
while the rest of the map still renders.

* fix(fleet): harden dependency map flag detection and remote merge

Address review findings on the dependency map:
- Port-conflict detection now does pairwise host-scope overlap, so an
  unrelated bind on the same port and protocol but a different specific host
  IP is no longer flagged, and the flag lands on the exact scoped port node.
- A running service's depends_on target is only considered satisfied when it
  is actually running, so a crashed (exited) dependency is surfaced while a
  deliberately stopped stack stays quiet.
- Declared external networks and volumes are reported missing when they do
  not exist on the host instead of being assumed present.
- The hub deep-validates each remote node-graph payload before merging, so a
  reachable-but-malformed remote degrades to a single node error rather than
  failing the whole fleet map, and the validation failure is logged.
- Searching or filtering on a network, volume, or port now also reveals the
  services that claim it and their stacks.
2026-06-06 11:18:36 -04:00
Anso 86bfc108ae fix(security): resolve open CodeQL path-injection and temp-file alerts (#1322)
Re-establish the path-containment barrier inline at the backup readdir
sink in restoreStackFiles. The sink previously built backupDir through
the getBackupDir helper, whose stack-name validation the static analyzer
does not trace, leaving a flagged path-injection sink. The barrier now
resolves backupDir against its root and asserts containment inline, the
same pattern already used in backupStackFiles. Behavior is unchanged for
valid stack names (already validated one line above by resolveStackDir).

Scope the js/insecure-temporary-file rule out of e2e/** in the CodeQL
config. End-to-end fixtures must seed files into the backend's COMPOSE_DIR
so the API under test can read them back; that path is a fixed location
under /tmp in both CI and local dev, which the rule flags. A randomized
temp directory does not apply because the backend resolves against its own
COMPOSE_DIR. Production code is still analyzed.
2026-06-05 23:02:08 -04:00
Anso f7f3afe05a feat(stacks): one-click import for stray compose files (#1320)
* feat(stacks): move discovered import candidates into place

The guided import flow previewed loose and nested compose files but could
not act on them, so it only told the user where to move files by hand. Add
an opt-in "Move into place" action: relocate a loose-root file into its own
<name>/ subfolder, or promote a nested stack directory one level up, so
Sencho's filesystem discovery lists it as a stack. The file stays a plain
compose file on disk; nothing is captured into a store. The move re-derives
the candidate from a fresh scan and matches by location, validates the
destination name and containment, resolves symlinks before the rename, and
never overwrites an existing stack. Backend and frontend both gate the
action on stack:create.

Also fix the rescan flicker: scan results now stay on screen while a rescan
runs (only the Rescan button shows progress) instead of the whole panel
collapsing to a spinner, and an empty rescan surfaces a toast.

* fix(stacks): make import-move destination creation atomic

The loose-root branch created the destination directory with mkdir
recursive after an access() existence precheck. If the destination
appeared between the check and the create, recursive accepted the
existing directory and the following rename could overwrite a
same-named compose file inside it, so the intended conflict response
never fired. Use a non-recursive mkdir so a destination that already
exists raises a conflict instead of being merged into. Add a regression
test that forces the precheck to miss and asserts the existing file is
left intact.

* fix(stacks): only offer not-yet-imported compose files in the import tab

The import tab listed every compose file in the compose directory,
including ones that are already stacks (a top-level subfolder with a
compose file), which just duplicated the sidebar. The scan now skips
those and surfaces only files that still need importing: a compose file
loose at the compose-dir root, or one nested a folder too deep.

Also harden the move-into-place write path that turns a stray file into
a stack: a failed rename after the destination folder is created now
rolls back the empty folder, so a retry is not blocked by a false
"already exists" conflict, and the move switches on an exhaustive set of
placements so a new one cannot silently take the wrong branch. The
sidebar refreshes after a move so the imported stack appears right away,
and the docs describe import as relocating a file, not capturing running
containers.

* fix(stacks): reject a nested import whose compose file escapes the base

The move-into-place path for a nested compose file validated only the
parent directory's real path, not the compose file itself. A directory
that is real and inside the compose base but holds a compose file
symlinked outside the base would survive the directory move and become a
stack whose compose file still points outside the base, which the editor
read path would then follow. The move now resolves the compose file too
and refuses it unless it stays inside the resolved source directory,
matching the loose-root check and the scan's preview reader.

* fix(stacks): satisfy CodeQL path and log analysis in import-move

The import-move write path built its destination directory from the
user-provided stack name through resolveStackDir, whose containment
barrier is wrapped in a helper that static analysis does not credit, so
every filesystem sink on the destination was flagged as path injection.
Re-establish the resolve-against-the-safe-base plus startsWith barrier
inline at the sinks, matching the read and backup paths in the same file,
and route the relocated file path through the same check. The name is
already restricted to an alphanumeric, hyphen, and underscore allowlist,
so the containment can never actually fail; this only makes the existing
safety visible to the analyzer.

Also log the move route's error as a sanitized message rather than the
raw error object, so a name embedded in an error message cannot forge log
lines.
2026-06-05 22:35:04 -04:00
Anso 308949282c feat(resources): reclaim banner controls and accurate reclaim math (#1318)
* feat(resources): reclaim banner controls and accurate reclaim math

Make the Resources Hub reclaim banner match what it advertises and give
operators control over when it appears.

- "Review & prune" now reclaims every category the banner lists (unused
  images, stopped containers, and dangling volumes) instead of images
  only, so the banner clears in one action. Pruning runs volumes first,
  while stopped containers still reference their named volumes, so a
  stopped stack's data is never cascaded into deletion.
- Add a "Show reclaimable-space banner" toggle under Settings, System,
  Docker hygiene (on by default, per node) and a dismiss control on the
  banner that snoozes it until the reclaimable total grows again.
- Fix the reclaimable-space math: count only containers a prune can
  actually remove (created, exited, dead) and size them by their writable
  layer, so a small, un-prunable remainder no longer keeps the banner up.

* fix(resources): show the reclaim banner when the settings fetch fails

A failed or empty /settings load left the banner's enabled flag at the
previously active node's value, so switching from a node with the banner
turned off to a node whose /settings errored kept the new node's banner
hidden. Set the flag unconditionally after the staleness guard so a
failed fetch falls back to the default-on state for the current node.
2026-06-05 18:59:33 -04:00
Anso 716daf77d0 feat(updates): auto-prune dangling images after updates (#1316)
* feat(updates): auto-prune dangling images after updates

Each update pulls a fresh image and recreates containers, leaving the
replaced image behind as a dangling layer that previously had to be
pruned by hand. A new "Prune dangling images after updates" toggle under
Settings > System > Docker hygiene reclaims these automatically.

The setting is on by default and opt-out. When enabled, a successful
stack update (manual or scheduled) and a Sencho self-update each remove
the dangling image layers they orphaned. Only untagged layers are
touched; tagged images, volumes, and data are never removed. The toggle
requires an admin account and is per node: each instance honors its own
value, so a remote node self-update applies that node's own preference.

A prune failure never affects the update result: on the stack path it is
caught and logged after the update has already succeeded, and on the
self-update path the helper-shell prune runs only after a clean recreate
and cannot change the exit code or the recorded update error.

* security(self-update): shell-quote label-derived values in helper command

Address review feedback on the prune-on-update change:

- The self-update helper command interpolated the compose service name and
  config-file paths (both read from Docker Compose labels) straight into a
  shell string. Shell-quote them via shQuote so a label carrying shell
  metacharacters stays inert data and cannot break the exit-code capture,
  error-file write, or prune guard.
- Correct the settings copy and docs: the prune is a standard dangling-image
  prune, so it reclaims every untagged layer on the node, not only the one the
  current update orphaned. Tagged images, volumes, and data remain untouched.
- Add tests: shell-metacharacter neutralization and prune-output suppression in
  the self-update command, and an atomic-update case asserting a prune failure
  does not trigger a rollback.

* fix(updates): omit the reclaim figure when the daemon reports zero bytes

End-to-end testing on a Docker daemon backed by the containerd image store
showed the post-update prune removing a dangling image while the prune API
returned SpaceReclaimed=0, so the stream printed "reclaimed 0.0 MB" even though
an image was removed. Show the reclaimed figure only when the daemon reports a
non-zero value; otherwise the line reads "=== Pruned dangling images ===". The
overlay2 store still reports real figures and shows them. Add a test covering
both branches.
2026-06-05 18:12:37 -04:00
Anso b4cca9b995 fix(blueprints): allow deleting blueprints stuck on awaiting-confirmation deployments (#1313)
* fix(blueprints): allow deleting blueprints stuck on awaiting-confirmation deployments

Withdrawing a stateful deployment removed its row, but the reconciler
re-created it as "Awaiting confirmation" for any node still matching the
selector. The delete guard counted that recreated row as blocking, so a
stateful blueprint could never be deleted: every withdraw came back as a
pending review and delete kept refusing.

The delete guard now blocks only on deployments that have a live stack on
a node (active, drifted, correcting, evict_blocked, or a pending review
that was previously deployed). A never-deployed "Awaiting confirmation"
deployment no longer blocks; the delete path's best-effort withdraw-all
loop and the foreign-key cascade clear those rows. Live stateful
deployments still require explicit withdrawal first, so the
snapshot-vs-destroy choice is always made by the operator.

Updates the delete dialog copy to match and adds delete-guard coverage.

* fix(blueprints): harden delete against destroying unmanaged or live stacks

Address two issues found while auditing the delete guard:

- A never-deployed deployment (for example a reconciler-created pending
  review) no longer blocked delete, but the route's withdraw-all loop
  still ran the withdraw primitive for it. withdrawFromNode proceeds on a
  missing marker, so deleting the blueprint could down and delete an
  unmanaged same-name stack on the node. The loop now withdraws only the
  stacks Sencho deployed and still owns, and skips never-deployed,
  name_conflict, and withdrawn rows.

- A stack that was deployed then failed keeps its last_deployed_at, but
  the guard's status list did not include failed, so it could be deleted
  without the snapshot-vs-destroy choice. The guard now keys on
  last_deployed_at (a stack we deployed and own) rather than a status
  list, so any owned live stack blocks regardless of its current status,
  while never-deployed, name_conflict, and withdrawn rows do not.

Adds delete-guard coverage for these paths, including that withdraw is
never invoked for an unmanaged row and is invoked for an owned one.
2026-06-05 14:29:09 -04:00
Anso 865d792874 feat(pricing): collapse to two tiers (#1309)
* feat(pricing): collapse to two tiers (Community + Admiral)

Collapse Sencho's pricing from three tiers (Community / Skipper / Admiral)
to two: a generous free Community tier and a single paid Admiral tier. The
Skipper tier is removed.

Now free in Community: auto-heal, auto-update, scheduled operations,
webhooks, notification routing, Fleet Actions and bulk operations, SSO
preset providers (Google / GitHub / Okta), unlimited users with admin and
viewer roles, and deploy safety (atomic deploys, auto-rollback, and
one-click rollback).

Admiral (paid) is focused on running and governing a fleet: blueprints,
Fleet Secrets, deploy enforcement, vulnerability report export, audit log,
host console, private registries, mesh networking, node cordon, managed
cloud backup, LDAP / Active Directory SSO, and the advanced RBAC roles
(deployer, node-admin, auditor) with per-resource scoped assignments.

Internally the license variant distinction is removed so tier is binary
(community / paid). License validation still verifies the Lemon Squeezy
store and product before granting paid status.

Docs and the contributor guide are updated to the two-tier model.

* docs(pricing): correct licensing page to two-tier pricing and tidy stale tier wording

The licensing docs page kept the old Admiral pricing plus a Founder
Lifetime column and an Enterprise paragraph after the two-tier collapse.
Update it to $12/month or $99/year, drop the lifetime and Enterprise
content, and link to the pricing page for current pricing.

Also fix stale "Skipper" wording in CLA.md, SUPPORT.md, one test title,
and three test comments. Historical CHANGELOG entries and the
retired-Skipper license-guard test are intentionally left as-is.

* docs: align licensing and SSO pages with the two-tier model

Correct the SSO overview so the Google, GitHub, and Okta presets read as
available on every tier, matching the provider table; only LDAP and Active
Directory require Sencho Admiral. Remove the lifetime-plan references from the
licensing, settings, and troubleshooting pages so they reflect subscription-only
Admiral pricing.

* fix(rbac): omit scoped permissions from /me on the Community tier

Scoped role assignments only take effect on the paid tier, but GET /api/permissions/me returned them unconditionally, so a downgraded instance with leftover assignments rendered per-resource affordances the API then rejected with 403. The endpoint now mirrors the permission middleware and includes scoped permissions only on the paid tier. Adds a regression test covering the downgrade case.

* docs: use custom-pricing wording on the contact page

The two-tier model has no Enterprise tier; reword the contact page's enterprise pricing/deals to custom pricing/deals so it does not imply a tier that no longer exists.
2026-06-04 17:45:53 -04:00
dependabot[bot] 7b78cb9cc9 chore(deps): bump the all-npm-backend group in /backend with 9 updates (#1306)
Bumps the all-npm-backend group in /backend with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [axios](https://github.com/axios/axios) | `1.16.1` | `1.17.0` |
| [isomorphic-git](https://github.com/isomorphic-git/isomorphic-git) | `1.38.3` | `1.38.4` |
| [otplib](https://github.com/yeojz/otplib/tree/HEAD/packages/otplib) | `13.4.0` | `13.4.1` |
| [systeminformation](https://github.com/sebhildebrandt/systeminformation) | `5.31.6` | `5.31.7` |
| [eslint](https://github.com/eslint/eslint) | `10.4.0` | `10.4.1` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.60.0` | `8.60.1` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.7` | `4.1.8` |
| [@aws-sdk/client-ecr](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ecr) | `3.1054.0` | `3.1061.0` |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1054.0` | `3.1061.0` |


Updates `axios` from 1.16.1 to 1.17.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.16.1...v1.17.0)

Updates `isomorphic-git` from 1.38.3 to 1.38.4
- [Release notes](https://github.com/isomorphic-git/isomorphic-git/releases)
- [Commits](https://github.com/isomorphic-git/isomorphic-git/compare/v1.38.3...v1.38.4)

Updates `otplib` from 13.4.0 to 13.4.1
- [Release notes](https://github.com/yeojz/otplib/releases)
- [Commits](https://github.com/yeojz/otplib/commits/v13.4.1/packages/otplib)

Updates `systeminformation` from 5.31.6 to 5.31.7
- [Release notes](https://github.com/sebhildebrandt/systeminformation/releases)
- [Changelog](https://github.com/sebhildebrandt/systeminformation/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sebhildebrandt/systeminformation/compare/v5.31.6...v5.31.7)

Updates `eslint` from 10.4.0 to 10.4.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.4.0...v10.4.1)

Updates `typescript-eslint` from 8.60.0 to 8.60.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.60.1/packages/typescript-eslint)

Updates `vitest` from 4.1.7 to 4.1.8
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.8/packages/vitest)

Updates `@aws-sdk/client-ecr` from 3.1054.0 to 3.1061.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ecr/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1061.0/clients/client-ecr)

Updates `@aws-sdk/client-s3` from 3.1054.0 to 3.1061.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1061.0/clients/client-s3)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: isomorphic-git
  dependency-version: 1.38.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: otplib
  dependency-version: 13.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: systeminformation
  dependency-version: 5.31.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: eslint
  dependency-version: 10.4.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: typescript-eslint
  dependency-version: 8.60.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: vitest
  dependency-version: 4.1.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: "@aws-sdk/client-ecr"
  dependency-version: 3.1061.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1061.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anso <dev@saelix.com>
2026-06-04 16:24:31 -04:00
Anso 0683aa9395 fix(settings): reflect role, tier, and node scope in settings panels (#1300)
* fix(settings): reflect role, tier, and node scope in settings panels

Three gate fixes so the Settings panels match what the backend enforces.

Non-admin roles saw editable fields and a Save button on System Limits,
Developer, and App Store, but writes require admin, so Save always failed.
These panels now render read-only for non-admins (controls disabled, Save
hidden) while still showing the values.

The Developer panel is node-scoped but read and wrote the controlling
instance regardless of the selected node, so a remote node's debug mode and
retention windows could not be changed from the UI. It now targets the
active node like System Limits.

The settings shell derived the Admiral entitlement locally; it now consumes
the backend-provided value the API authorizes against, and that value is
corrected to require an active paid tier so an expired Admiral license no
longer reports as Admiral.

* fix(settings): gate audit_retention_days writes behind Admiral

audit_retention_days configures the Admiral-only audit log (the audit-log
routes require Admiral, and the Developer settings UI only shows the field to
Admiral operators), but the settings POST/PATCH handlers only required an admin
role. A non-Admiral admin (for example Skipper, or an expired-Admiral admin
whose tier dropped to community) could still set it through the API.

Gate writes to that key with requireAdmiral on both the single-key POST and the
bulk PATCH paths, matching the audit-log routes and the UI. Other keys remain
writable by any admin.

* fix(settings): reject unknown keys on PATCH instead of silently stripping

The bulk settings PATCH validated the body with a Zod object schema that
strips unknown keys by default, so a request carrying a disallowed key (for
example an auth_* secret) returned 200 as a no-op instead of being rejected.
No secret was written, but it diverged from the single-key POST path, which
rejects disallowed keys, and could hide client drift.

PATCH now rejects any key outside the allowlist with a 400 before validation
or write, keeping the bulk path fail-closed and consistent with POST.
2026-06-04 01:49:23 -04:00
Anso 4596a90474 fix(settings): serialize GET /api/settings from an allowlist (#1299)
The settings read returned the entire global_settings map minus a small
denylist of auth keys. Other subsystems persist their config in the same
table, so cloud backup values (endpoint, bucket, access key) were returned
to any authenticated user, including read-only roles, bypassing the
redaction the dedicated cloud-backup endpoint applies.

Project the response from the operational allowlist instead, so only the
keys the settings UI reads are returned and any key written to
global_settings by another subsystem is excluded by default. Remove the
now-unused denylist constant.
2026-06-04 01:49:03 -04:00
Anso 42fc8048fe fix(metrics): host memory usage excludes reclaimable page cache (#1297)
* fix(metrics): host memory usage excludes reclaimable page cache

Host RAM was computed as mem.used / mem.total via systeminformation, but mem.used counts
reclaimable buffers/cache as used, so a busy Linux host read ~99%. Switch the dashboard stats,
the fleet node self-report, and the host-RAM alert threshold to mem.active / mem.total
(cache-excluded), and report used: mem.active and free: mem.available so the byte readout stays
consistent with the percentage. Add regression tests for a cache-heavy host (no false alert) and
a genuinely busy host (alert still fires).

* test(metrics): assert system stats memory excludes reclaimable cache

The cached /api/system/stats test mocked si.mem() without active/available, so after
the route switched to the cache-excluded fields it produced a NaN percentage that the
shape-only assertions did not catch. Fill the mock with a realistic shape and assert the
route reports the active working set, not the cache-inclusive used/free.
2026-06-03 16:11:00 -04:00
Anso 2435da232b fix(api-tokens): harden rate limiting and surface list-load errors (#1292)
* fix(api-tokens): scope per-token rate limits to live tokens

Forged or token-shaped Authorization headers no longer mint their own
rate-limit budget. The key generator now grants a per-token budget only to
a real, active token and falls back to per-IP keying for anything else, so
a single source cannot evade the global limiter by rotating fake tokens.
The validated token is memoized on the request, so authentication reuses
it without a second database lookup.

Token validation (format, checksum, lookup, revocation, expiry) is now a
single shared helper used by the HTTP auth middleware, the WebSocket
upgrade handler, and the rate-limit key generator, replacing two
near-identical inline copies that could drift apart. The last-used
timestamp write is throttled so a busy token no longer writes to the
database on every request.

* fix(api-tokens): surface token list-load failures with a retry

A failed load of the API tokens list was swallowed: a server error
rendered the empty "no tokens yet" state with no sign that anything went
wrong. The list now shows an error card with a Retry action and raises a
toast on any non-ok response or network error, matching the create and
revoke flows. Adds a troubleshooting entry for the error.

* test(api-tokens): seed tokens via the shared test helper

The new hardening and WS-scope suites computed sha256 of a raw token
directly, which CodeQL flags as js/insufficient-password-hash (a false
positive: these are 256-bit CSPRNG opaque tokens, not passwords). Route
token creation through the existing apiTokenTestHelper and read the stored
token_hash back from the row, so the suites no longer hash anything
themselves. Also removes the duplicated createToken helpers.

* fix(api-tokens): key the rate limiter by the same credential auth uses

The rate-limit key generator checked the session cookie before the
Authorization bearer, while authMiddleware authenticates bearer-over-cookie
(bearerToken || cookieToken). A request could send a Bearer API token plus a
forged cookie and be keyed by the cookie's (forgeable, rotatable) username,
sidestepping the per-token / per-IP keying the limiter applies to API tokens:
a valid token would lose its own bucket, and a forged token-shaped bearer
would no longer collapse to per-IP.

Reorder the generator to mirror auth: process the bearer first (validate the
API token and key per-token or fall back to per-IP; otherwise decode the JWT
by username/sub), and consult the cookie only when there is no bearer.
Regression tests cover a valid and a forged sen_sk_ bearer, each sent with a
forged cookie.
2026-06-03 08:18:16 -04:00
Anso ae0b9d166c fix(git-sources): return 200 for stacks without a Git source (#1294)
The dashboard probes GET /api/stacks/<name>/git-source for every stack to
decide whether to show a Git badge. For a stack with no Git source attached
the endpoint answered 404, so a fleet of unlinked stacks painted a red 404
per stack in the browser console.

Return 200 { linked: false } when the stack exists but has no Git source,
and reserve 404 for the genuine "stack does not exist" case (mirroring the
existence guard the PUT handler already uses). The two consumers that read
this endpoint now treat the { linked: false } sentinel as unlinked rather
than as a configured source.
2026-06-02 22:02:49 -04:00
Anso c68f0b494c test(stacks): drain pending download-metric records between tests (#1293)
The "records the download metric exactly once" test in stack-files-routes
intermittently failed with "expected 2 to be 1". The download route records
its metric asynchronously, on the file stream's end/close and the response's
close event, so a prior download test's record could land after the next test
called resetFileExplorerMetrics(), leaking a count across the reset. The
route's per-request recording is correct (a synchronous guard); the leak was
purely cross-test in the suite.

Add an afterEach in the download suite that waits for the download count to
settle (bounded poll) before the next test runs, so no pending record survives
the reset, and assert the count actually settled so a runaway record surfaces
rather than being silently swallowed.
2026-06-02 21:54:01 -04:00
Anso 5289f01bfd feat(onboarding): add first-run environment checker (#1290)
* feat(onboarding): add first-run environment checker

Add a preflight that checks whether the host can run Docker deploys before a
deploy fails for an avoidable reason. It verifies the Docker engine is reachable
and permitted, the Compose plugin is present, the compose directory is writable
and mounted at a matching host path, the dashboard is behind TLS, and the
compose volume has disk headroom. Each result that needs attention carries a
specific fix rather than a generic error, and the checks never block: an
operator who knows their setup can continue.

The checks run as the final step of first-boot setup and can be re-run any time
from the Recovery settings tab. A new admin-only endpoint,
GET /api/diagnostics/environment, backs both surfaces.

* fix(onboarding): distinguish unverified path mapping and support parent binds

Treat a container whose self-inspect fails as an unverified path-mapping warning
instead of a false "not containerized" pass, so an unverifiable mapping never
reads as healthy. Resolve the compose directory through the longest-prefix bind
mount and compare the host path it resolves to, so a parent bind such as
-v /opt:/opt correctly covers COMPOSE_DIR=/opt/compose instead of warning that
the directory is not bind-mounted.

* test(e2e): advance the setup wizard past the environment step in loginAs

The first-run setup helper clicked "Initialize console" and immediately waited
for the dashboard, but setup now shows an environment-preflight step before
landing the console. Click "Enter Sencho" to complete onboarding before
asserting the dashboard, so the first test on a fresh instance passes.
2026-06-02 21:40:38 -04:00
Anso 653be3296b fix(stacks): name the body field in the stack-create required error (#1289)
The create endpoint reads the new stack name from the body field
stackName, but a missing or non-string value returned "Stack name is
required and must be a string", which points at a value problem and
leaves anyone scripting against the stacks API unsure which field to
fix. Name the field in the message so the cause is unambiguous, and add
regression coverage for the missing and non-string cases.
2026-06-02 19:53:45 -04:00
Anso 5e6e96e405 feat(mesh): add developer-mode diagnostics to the mesh data plane (#1287)
* feat(mesh): add developer-mode diagnostics to the mesh data plane

Add a developer-mode-gated [Mesh:diag] diagnostic log to MeshService,
following the existing diagnostic-logging pattern: gated on the shared
developer_mode setting (off in production by default) and every value run
through sanitizeForLog. The diagnostics cover the forwarder dispatch
decision (which self-node resolution won, and whether the route is
same-node or cross-node), the entry of the opt-in, opt-out, enable, and
disable operations, and completion timing for opt-in, node disable, and
the alias-cache refresh. This lets an operator trace a slow or stuck mesh
operation from the logs without attaching a debugger. The calls sit only
at per-operation, per-accept, and per-refresh cadences, never inside the
per-frame byte-relay loops.

Add tests covering the developer_mode on/off gating and a guard that a
node's api token never reaches a diagnostic log or the activity buffer.

* refactor(mesh): redact secret-shaped values in developer-mode diagnostics

Run each [Mesh:diag] detail through redactSensitiveText before
sanitizeForLog so a future caller cannot leak a Bearer token, JWT, or
credentialed URL through a diagnostic line, regardless of which call site
emits it. No current call site passes a secret; this is defense in depth.
Use a JWT-shaped canary in the no-leak test so it guards the real credential
class.
2026-06-02 16:12:13 -04:00
Anso c6d1631afe feat(recovery): add safe-mode recovery surface and emergency CLI (#1286)
* feat(recovery): add safe-mode recovery surface and emergency CLI

Add a read-only Recovery tab under Settings (admin-only) backed by a new
GET /api/diagnostics endpoint reporting app version, database integrity,
encryption-key status, Docker reachability, account and SSO counts, and
non-secret configuration. The endpoint loads without Docker or live metrics
so it stays available when the dashboard does not, requires a genuine admin
session, and builds its config block from a non-secret allowlist so no
credentials are ever exposed.

Expand the emergency command-line toolkit beyond the two-factor reset with
seven host-level commands: reset-password, create-emergency-admin,
clear-sessions, disable-sso, diagnostics, validate-db, and backup-data. Each
prints its result, exits with a meaningful status code, and writes an audit
entry where it changes state.

Document the toolkit in a new operator guide and link it from the recovery
and two-factor pages.

* feat(recovery): download the emergency command reference as a text file

The recovery commands are needed exactly when the dashboard is unreachable,
so reading them only in-app is a chicken-and-egg problem. Add a Download
button to the command-line section that saves the full
`docker compose exec sencho ...` reference as a text file, letting operators
keep it on hand before they need it. Reuses a shared download helper with the
existing diagnostics export.

* fix(recovery): harden diagnostics, backup, and emergency-admin against edge cases

Address findings from an independent review of the recovery toolkit:

- DiagnosticsService now degrades instead of throwing when a queried table is
  missing or corrupt: each read falls back and is folded into database.ok, so a
  broken database reports "problem detected" rather than failing the whole
  endpoint or showing a misleading healthy state with zeroed counts.
- backup-data refuses a destination that resolves to the live database, which
  would otherwise report success while producing no separate copy.
- create-emergency-admin now applies the same username rule as the user-
  management route, extracted to a shared helper so both stay in sync.

Adds tests for a missing read table, a malformed emergency-admin username, and
the backup same-target rejection.
2026-06-02 16:11:24 -04:00
Anso 06b25262cc feat(stacks): guided first stack import flow (#1285)
* feat(stacks): add guided first stack import flow

Add an Import mode to the Create Stack dialog and a zero-stacks empty
state so a new user who already has compose files on disk can land their
first stack without reading the docs first.

A read-only scan of the compose directory (GET /api/stacks/import/scan)
lists the compose files it finds with a dry preview of each file's
services, ports, volumes, and env files. Each result is labelled by
placement: already a stack, loose at the root of the compose directory,
or one folder too deep, with the exact path to move misplaced files to.
The scan never writes, moves, or changes any files.

Manual stack creation (Empty, From Git, From Docker Run) is unchanged.

* fix(stacks): read import-scan candidates via a single file handle

Open the compose file once and stat plus read on the same descriptor so
the size check and the read observe the same inode, instead of resolving
the path twice (stat then readFile), which is a time-of-check/time-of-use
race. Mirrors the existing handle-based readers in FileSystemService.

* fix(stacks): confine import scan to the compose dir and refine the empty state

Harden the read-only import scan:
- Resolve symlinks and confirm the real target stays inside the compose
  directory before reading a candidate, and reject non-regular files, so a
  symlinked compose file or parent cannot expose a file outside the compose
  directory through the preview (matches resolveSafeStackPath).
- Read at most the stat-reported size (bounded by the 1 MiB cap) from the open
  handle, so a file that grows after the size check cannot exceed the cap.
- Log when the compose directory or a subdirectory cannot be read, so an access
  failure is not silently reported as "no compose files found".

Only show the first-run "No stacks yet" prompt when no filter chip is active, so
a filter that matches nothing is not mistaken for an empty fleet.
2026-06-02 16:10:05 -04:00