566 Commits

Author SHA1 Message Date
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
Anso c82a39c65a fix(mesh): hide node and stack management controls from non-admins (#1284)
* fix(mesh): hide node and stack management controls from non-admins

The Routing tab rendered the per-node mesh enable/disable toggle and the
stack opt-in/opt-out controls for any Admiral-tier user, but those backend
routes require the admin role. A non-admin viewer on an Admiral instance
saw controls that returned 403.

Thread a canManage flag (true only for admins) from the Fleet view into
the Routing tab, its node cards, and the opt-in sheet so non-admins get a
read-only Routing tab: the enable/disable toggle, add-stack, and
opt-in/opt-out controls are hidden, while status, aliases, topology,
activity, diagnostics, and the alias test probe stay available. This
mirrors the Federation tab's existing read-only treatment for non-admins.

Add backend route-gating tests covering the tier and admin-role guards on
every mesh route, and frontend render-gate tests for the node card and the
opt-in sheet in both density layouts.

* refactor(mesh): require canManage on the routing-node-card primitive

Remove the permissive `canManage = true` default on the shared
routing-node-card primitive so a new call site cannot render the
management controls without an explicit decision. Every current caller
already passes the flag; the type now enforces it. Drop the omitted-prop
test, which covered a state the compiler now prevents.
2026-06-02 16:09:25 -04:00
Anso 65a69d9ecc fix(nodes): never send stored node tokens to clients (#1281)
Node read endpoints now return a client-safe projection that omits the
stored api_token and exposes a has_token boolean instead, so a node's
long-lived proxy credential is never serialized to a browser or API
token client. The token stays encrypted at rest and is read server-side
only by the components that need it (the remote proxy, the connection
test, and the mesh dialer).

The edit form opens the API Token field blank, and a blank value keeps
the existing credential, so saving an edit without retyping the token no
longer clears it; a non-empty value rotates it. The backend enforces the
same rule defensively.

Node management actions (add, edit, delete, test connection, generate
node token) are gated in the UI to match their server-side permission
checks, so operators no longer see an action the API would reject. The
test-connection route also gains the missing server-side permission and
token-scope guards.

Also validate the x-node-id header and fall back to the default node for
malformed values instead of an obscure 404, and return 400 (not 500)
when deleting the default node.
2026-06-02 10:26:31 -04:00
Anso 35a1182890 fix(nodes): gate node-management actions by role and release pilot tunnels on delete (#1280)
* fix(nodes): gate node-management actions by role and release pilot tunnels on delete

The node-management write actions in the Nodes panel (add, edit, delete,
generate node token, reset fleet-sync anchor) rendered for every signed-in role,
but the API enforces the manage-nodes permission on them, so lower-privilege
roles saw buttons that returned 403. The panel now renders each action against
the same permission its route enforces; the read-only node table stays visible
to every role.

Deleting a node now also tears down its live pilot-agent tunnel (and any mesh
bridge) immediately, releasing the loopback server, heartbeat timer, and open
streams instead of leaving them until the agent next disconnects, matching the
cleanup the re-enrollment path already performed.

Adds backend route tests for the permission boundaries and tunnel teardown, and
a Nodes panel render test covering the viewer, admin, and node-admin views.

* fix(nodes): close proxy mesh bridges via the dialer on node delete to skip a redial

Deleting a node closed any active mesh bridge through PilotTunnelManager, but a
proxy-mode bridge is owned by the mesh dialer, whose close listener then treated
the close as unexpected and scheduled a reactive redial against the node being
removed. The delete handler now closes a proxy bridge through the dialer's
intentional-close path first (which suppresses the redial), then closes a
pilot-agent tunnel as before. Adds a backend test that primes a live proxy
bridge and asserts deletion closes it without scheduling a redial.
2026-06-02 09:51:42 -04:00
Anso 5af0c043d5 fix(stack-files): record download metric when response closes after a full read (#1282)
The file-download handler recorded its in-process download metric off the
source stream's end/close events. When the response consumer closed right
after receiving the whole file, those source events could be dropped and the
metric was never recorded, so the per-node download counter undercounted
successful reads under load.

Record the success directly on response close once the file has been fully
read, instead of waiting on source events that may never arrive. The abort
path (partial read) is unchanged and the recorder stays idempotent, so a
later source end/close cannot double-count. Adds a deterministic regression
test for the response-close-after-full-read path.
2026-06-02 09:49:08 -04:00
Anso dd2c2b22ec fix(federation): gate node cordon control on node:manage to match backend (#1277)
* fix(federation): gate node cordon control on node:manage to match backend

The cordon and uncordon control on the node card rendered whenever the
instance held an Admiral license, but the backend route also requires the
node:manage permission. Non-admin users without that permission (deployer,
viewer, auditor) saw a Cordon action the API rejected with 403. Gate the
control on the same permission the route enforces, so it renders only for
users who can use it.

Add developer-mode diagnostic logging to the cordon and pin handlers, and
route-level tests covering the cordon and uncordon permission, tier,
API-token, and input-validation boundaries.

* fix(federation): reject non-numeric node ids in cordon/uncordon

The cordon and uncordon routes validated the node id with parseInt, which
accepts numeric-prefix strings (parseInt('1abc', 10) === 1), so a request to
/api/nodes/1abc/cordon would operate on node 1. Require a strict positive
integer before parsing, and add tests for both routes.

* fix(federation): sanitize user-derived values in cordon and pin diagnostics

Route the node id, blueprint id, target node id, and reason length through
the shared log sanitizer before they reach the developer-mode diagnostic
log lines, and switch the format specifiers to %s to match. The values are
already validated integers, so this is defense in depth at the log sink and
keeps the diagnostics on the same sanitize-every-interpolated-value pattern
used elsewhere. No runtime behavior change: the lines stay gated behind the
developer_mode setting, off by default.
2026-06-02 09:48:19 -04:00
Anso e60c1c0525 feat(fleet-snapshots): add restore-all, per-file download, and scrollable preview (#1276)
Three improvements to the Fleet Snapshots detail view, matching the admin-only
access of the existing per-stack restore:

- Restore all: a control in the snapshot header restores every captured stack
  across the fleet in one action, with an optional "redeploy all after restore"
  checkbox. Each stack is restored independently, so a failure on one (removed
  node, offline remote, blocked deploy) is reported per stack while the rest
  still proceed. Backed by POST /api/fleet/snapshots/:id/restore-all.
- Per-file download: each compose or .env file in a snapshot can be saved to
  disk individually from its row.
- Scrollable preview: the inline file preview is now a bounded, scrollable
  panel, so a long compose file can be read in full instead of being clipped.

Remote restore and redeploy failures now carry the remote node's status and
reason, so a per-stack failure in Restore all is actionable.
2026-06-02 08:58:54 -04:00
Anso 9fb4ccccff fix(fleet-secrets): restrict bundle management to admin hub sessions (#1274)
* fix(fleet-secrets): restrict bundle management to admin hub sessions

Fleet Secrets exposes decrypted environment-variable values and writes
credentials across the fleet, so every route now requires an admin role,
runs only on the instance you are signed into, and rejects long-lived API
tokens:

- Add an admin-role check to all secrets routes; the frontend Secrets tab
  renders only for admin users so the affordance matches the backend gate.
- Add /api/secrets/ to the hub-only path list so a request carrying a
  remote node id cannot be proxied to read another node's decrypted values.
- Reject API tokens on every secrets route (browser admin sessions only),
  matching how registry credentials are handled.

Also adds lifecycle and developer-mode diagnostic logging (never the secret
values) and tests covering the admin boundary on every endpoint, API-token
rejection, hub-only enforcement, and diagnostic gating.

* fix(fleet-secrets): require a signed-in user session for all secrets routes

The earlier API-token rejection only blocked opaque API tokens. node_proxy
and pilot_tunnel JWTs are mapped to an admin role by the auth middleware
without an API-token scope, so they still passed the admin gate and could
read decrypted bundles via GET /api/secrets/:id.

Replace the API-token check with requireUserSession, which rejects API tokens
and node_proxy / pilot_tunnel machine credentials (userId 0) on every secrets
route, returning SESSION_REQUIRED. The admin role is still enforced after.

Tests now assert SESSION_REQUIRED for a full-admin API token across all nine
routes and for node_proxy and pilot_tunnel JWTs.

* test(fleet-secrets): mint the rejection-test token via the real endpoint

The machine-credential test reconstructed an API token by sha256-hashing a
raw key inline. That duplicated a hashing sink that CodeQL's
js/insufficient-password-hash query flags (a false positive for a 256-bit
random token, but a new occurrence in the diff). Create the token through
POST /api/api-tokens instead, so the hashing stays in the production path
and the test carries none of its own. Behavior and coverage are unchanged.
2026-06-01 19:47:06 -04:00
Anso c11a550b6a fix(fleet-snapshots): gate reads on admin role and encrypt content at rest (#1273)
* fix(fleet-snapshots): gate reads on admin role and encrypt content at rest

Fleet snapshots capture every node's compose.yaml and .env, so the data is
as sensitive as the live stacks. This hardens access and reliability across
the snapshot pipeline.

- Restrict snapshot reads to administrators. GET /api/fleet/snapshots and
  /:id now require the admin role, matching create, restore, and delete; the
  Fleet "Snapshots" tab and its panel render only for admins. Previously any
  authenticated user could enumerate snapshots and read every node's .env.

- Encrypt snapshot file contents at rest with the instance key. Restore and
  cloud-archive paths decrypt on read, so cloud archives stay portable and a
  database copy no longer exposes stack secrets in plaintext. Rows written
  before this change still read back as plaintext.

- Surface partial captures. A stack whose compose file cannot be read or
  fetched, or a file over the 1 MB capture cap, is recorded as a warning and
  shown on the snapshot instead of being silently dropped, so a snapshot is
  never mistaken for complete. Remote .env read errors are now distinguished
  from a genuinely absent .env.

Adds route-authz, capture-warning, and encryption round-trip tests; updates
the Fleet-Wide Backups feature docs.

* fix(fleet-snapshots): gate cloud snapshot reads on admin role

The cloud snapshot read routes were guarded by provider/license only, not by
role, while their write counterparts (upload, delete) already required admin
and the Cloud Backup settings surface is admin-only. Because a downloaded
archive contains plaintext compose and .env files, a non-admin could list and
download cloud snapshots and read every node's secrets, the same exposure the
local snapshot reads were just closed against.

- Require admin on GET /api/cloud-backup/snapshots, /status/:id, and
  /object/:keyB64/download, matching the local snapshot reads and the
  admin-only Cloud Backup settings section.
- When capturing a remote node, treat a 200 response carrying X-Env-Exists:
  false as a stack with no .env (matching the local ENOENT path) instead of
  storing an empty .env that restore would later write back.

Adds non-admin authorization tests for the cloud read routes and a remote
absent-.env capture test.
2026-06-01 17:27:59 -04:00
Anso 0953025036 fix(fleet): gate node update actions to admins and harden update tracking (#1272)
* fix(fleet): gate node update actions to admins and harden update tracking

Node update affordances now render only for admins, matching the admin-only
routes behind them. Previously a non-admin could open the Fleet view and see the
per-node Update button, Update all, retry, dismiss, and Recheck controls, then
get a 403 on click. Those controls are now hidden for non-admins, who still see
read-only update status.

Both update-status clear routes (per-node and bulk) now require admin, and the
bulk recheck throttles its forced "latest published version" lookup so a caller
cannot loop it to hammer the upstream registries; the response reports whether
the refresh actually ran so the UI can surface a "checked recently" note.

Completion detection no longer reports a node as Updated when it merely blips
offline and returns on the same version with an unchanged process start time.
That case stays in progress and is decided by the existing early-fail and
timeout heuristics, so a momentary network glitch is not mistaken for a
successful update. Failed and timed-out updates now emit an operator-visible
warning, and a periodic safety-net sweep bounds in-flight trackers when no
client is polling for status.

* fix(fleet): harden update completion and recheck failure handling

Refinements from review of the node self-update hardening:

- Completion signal 1 now requires a valid version, not merely a different one.
  A node whose /api/meta momentarily omits or mangles its version (online, same
  process) reported version=null, which compared unequal to the previous version
  and falsely marked the update completed. It now stays in progress and is
  decided by the early-fail/timeout heuristics.

- Terminal resolution is atomic: it re-reads the live tracker and transitions
  only if it is still in flight with the same start time, so two concurrent
  status polls cannot both warn or clobber each other's transition.

- The operator warning for a failed or timed-out update now redacts
  secret-shaped text (bearer/basic/token/password, credentialed URLs) from the
  underlying error before logging, in addition to stripping control characters.

- The Recheck button now surfaces an error toast when the request throws
  (network or auth failure), matching the existing non-ok-response path instead
  of only logging to the console.
2026-06-01 17:27:25 -04:00
Anso 7d7e0a6264 feat(fleet-sync): gate sync-status polling on admin role (#1271)
* feat(fleet-sync): gate sync-status polling on admin role

The fleet sync status poll requires a paid admin on the server, but the
useFleetSyncStatus hook only checked the paid tier. A paid non-admin who
opened the Fleet Status tab or Settings, Nodes would poll the endpoint
every 30 seconds and get a 403 each time, with the policy sync rows
silently never loading. Gate the hook on both paid tier and admin role
so the client mirrors the server and the request only fires when it can
succeed.

Also add a one-time log when an instance transitions from control to
replica, and a real-database integration test covering the full receive
path: role flip, identity and watermark persistence, wholesale row
replacement with local rows preserved, replica read-only enforcement,
and the stale-watermark and control-anchor rejection branches. Includes
a hook test asserting the gate across the paid and admin matrix.

* fix(fleet-sync): drop sync-status rows that resolve after the gate flips

A sync-status fetch started while the user was an eligible paid admin
could resolve after the user lost eligibility mid-flight (role or tier
change), re-populating the status rows that the gate-false path had
already cleared. That briefly re-enabled the policy-sync rows and the
anchor-mismatch banner for a now-ineligible client.

Track the latest gate state in a ref and skip the state writes when the
fetch resolves after eligibility was lost, so the rows stay empty under
role and tier transitions. Adds a regression test for the late-resolve
path.

* fix(security): sanitize control identity before logging replica transition

The control to replica transition log interpolated the control fingerprint
straight from the inbound sync payload, so a sibling pushing a forged
controlIdentity with embedded CR/LF could split or spoof log lines. Wrap the
value in the shared sanitizeForLog helper, matching every other tainted log
sink, so control characters are stripped before the entry is written.
2026-06-01 17:26:11 -04:00
Anso 7e0cffa376 fix(fleet-actions): stop-by-label works on Community remote nodes (#1270)
* fix(fleet-actions): stop-by-label works on Community remote nodes

Fleet-stop's remote leg fanned out to POST /api/labels/:id/action, which
is gated to Skipper/Admiral, so on a Community fleet the control node
stopped its own stacks but every remote node returned 403. Fleet-stop
itself is admin-only and available on every license, so the remote leg
contradicted the feature's own gate.

Extract the label-match plus bulk-stop logic into a shared
runLocalLabelStop helper and add an admin-only, every-license
POST /api/fleet-actions/labels/local-stop receiver. The control now fans
out to that receiver, so remote stacks stop on every tier. Each node runs
under its own per-node bulk lock, so a fleet-stop and a per-label action
still serialize cleanly instead of double-stopping containers.

Also degrade the control's own leg per-node instead of failing the whole
fan-out when its filesystem read throws, and gate fleet-stop and
fleet-prune diagnostics behind developer_mode.

Tests: local-stop auth, tier, validation, and behavior; a remote-leg
routing guard that asserts the fan-out targets local-stop and never the
paid route; local-leg graceful degradation; and the three Fleet Action
card UIs.

* fix(fleet-actions): honor the remote stop receiver's matched flag

The control reached the remote leg only because its own mirror had the
label, then hardcoded matched:true and trusted results without guarding
its shape. A mirror-skewed control (mirror has the label, remote does
not) then showed a remote mismatch as "matched, 0 stacks" instead of
"no matching label", and a malformed 200 body could flow a non-array
into the per-stack renderers.

Honor the remote's own matched flag and coerce results to an array when
the body is malformed. Add regression tests for the matched:false skew
case and the non-array results case.
2026-06-01 14:18:44 -04:00
Anso 085267b466 feat(security): make CVE suppressions optionally honored by deploy-block policies (#1269)
* feat(security): make CVE suppressions optionally honored by deploy-block policies

Block-on-deploy policies evaluate the raw scan result, so a CVE an admin
has accepted in CVE Suppressions still blocks the deploy. Add an opt-in,
per-instance toggle ("Honor suppressions in deploy blocks", Settings ->
Security) that, when on, re-derives each image's severity from the
suppression-filtered findings before comparing to the policy threshold. A
deploy that proceeds only because suppressions dropped it below the gate is
recorded in the audit log. Default off, so the strict raw-scan behavior is
unchanged unless an operator enables it.

The setting governs the instance that runs the deploy and is not
fleet-replicated. The gate fails safe: a suppression-read error or an
empty detail set falls back to raw scan severity rather than dropping it.

Also surface a previously swallowed error in the CVE suppressions and
misconfig acknowledgement settings panels so a failed list load shows a
toast instead of an empty list.

* fix(security): gate on raw severity when preflight detail rows are truncated

The suppression-aware deploy gate re-derived image severity from the stored
vulnerability_details rows, assuming any non-empty set was complete. A cached
pre-deploy scan keeps the full aggregate counts but copies only a bounded slice
of detail rows, so recomputing from that slice could drop an unsuppressed
blocking CVE below the threshold and let a deploy through.

Guard the recompute: when the loaded detail rows do not match the scan's total
finding count, gate on the raw scan severity (never drops severity). Suppression
awareness still applies for scans whose details are stored in full, which is the
common case.
2026-06-01 13:39:46 -04:00
Anso d8f73f8203 feat(fleet): show stack-label filtering in Fleet View on every tier (#1268)
* feat(fleet): show stack-label filtering in Fleet View on every tier

Stack labels and their assignments are a Community feature: the per-node
label reads are available to any authenticated user and are already used in
the per-node Stacks view. Fleet View, however, only fetched the fleet-wide
label palette and per-stack chips when the instance was on a paid tier, so a
Community user who had labelled their stacks saw no label dots on node cards
and no Tags filter in the Overview toolbar.

Drop the paid gate on the fleet label fetch so the palette, the per-stack
chips, and the Tags filter render for everyone who has labels. Node-level tag
aggregation (used for topology grouping) stays paid and is unchanged.

* fix(fleet): surface update-status failures and soften the reconnect timeout

Three reliability fixes in the Fleet View update path:

- The fleet update-status poll swallowed fetch errors in an empty catch, so a
  failing poll left a silently stale table with no breadcrumb. It now logs the
  failure (both thrown errors and non-ok HTTP responses) without toasting on
  every tick, and keeps the last-known statuses.
- The local-update reconnecting overlay declared "Update timed out" after five
  minutes, which falsely reported failure when a large image pull simply ran
  longer than the reconnect window. It now shows a non-failure "Taking longer
  than expected" state with a "Reload to check" action, and the timeout is a
  named constant that mirrors the backend update timeout.
- The fleet overview fan-out already logged a node that failed to report; the
  update-status and update-all fan-outs now log the rejected node and reason
  too instead of discarding it.

* test(fleet): backfill Fleet View hook, component, and update-tracker coverage

Adds unit and component coverage for the previously untested Fleet View
surface: all six Overview hooks (overview, update-status, polling cadence,
preferences, fleet labels, node labels), the NodeCard, OverviewTab,
OverviewToolbar, NodeUpdatesSheet, UpdateStatusBadge, and ReconnectingOverlay
components, and the FleetUpdateTrackerService state transitions.

* test(fleet): assert update-status poll preserves last-known statuses on failure

Adds an explicit case that seeds statuses from a successful poll, then fails
the next poll, and verifies the table keeps the seeded statuses (and logs
without toasting) rather than relying on the implementation implicitly.
2026-06-01 13:20:36 -04:00
Anso 18762fa74b fix(registries): keep registry endpoints local to each instance (#1267)
Registry credentials are stored and resolved per instance, and the UI
only ever calls registry endpoints with localOnly. The endpoints were
not on the hub-only proxy list, so a scripted request carrying an
x-node-id for a remote node would be forwarded by the remote proxy,
carrying a plaintext registry secret to (or reading stored credentials
from) the remote instance.

Add /api/registries/ to HUB_ONLY_PREFIXES so a remote-targeted registry
request is rejected with 403 before the proxy can forward it, matching
the existing defense-in-depth for the audit log, schedules, and the
global logs feed. Add regression tests for the collection, a sub-path,
and the local pass-through. Correct a stale 409 reference in the guard
comments and test header to the 403 the guard actually returns.
2026-06-01 13:16:56 -04:00
Anso b61388c675 fix(rbac): enforce admin seat cap on promotion and harden last-admin and audit paths (#1266)
Promoting a user to admin now respects the per-tier admin seat limit the same
way user creation does, closing a path that let an operator exceed the cap by
creating an account and then editing its role to admin.

The last-admin guard for demote and delete now runs the admin-count re-check
and the write in a single transaction, so two concurrent admin changes can no
longer race the admin count to zero and lock everyone out.

Admin two-factor resets are now recorded once with their own audit summary
instead of being mislabeled as a user creation by the audit middleware.
2026-06-01 13:01:22 -04:00
Anso d4fa4a4965 fix(host-console): audit session lifecycle and harden path, resize, and route gating (#1263)
* fix(host-console): audit session lifecycle and harden path, resize, and route gating

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

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

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

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

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

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

When the requested node's directory cannot be resolved, the session falls
back to the default node's base directory. Record that fallback node in the
session audit row (and log it) so the audit trail names the node the shell
actually runs in rather than the originally requested one. Strengthen the
upgrade integration test to assert the open row captures the user, node, and
client IP.
2026-05-31 20:29:30 -04:00
Anso 7e65a2ae19 fix(mfa): enforce single-use backup codes under concurrent verification (#1262)
* fix(mfa): enforce single-use backup codes under concurrent verification

Backup-code consumption read the stored hash set, awaited bcrypt.compare,
then wrote the shrunk set back. Two concurrent /login/mfa requests carrying
the same code could both read the same set, both match, and both persist,
so a single backup code yielded two authenticated sessions.

Move consumption into a synchronous transaction: verifyBackupCode now returns
the matched hash without mutating state, and a new consumeBackupCodeHash
re-reads and shrinks the set atomically, returning whether the hash was still
present. Login gates success on that result, so exactly one concurrent request
wins. Reshape the verify result into a discriminated union so a match always
carries its hash.

Add coverage: a deterministic consume test (same hash twice, distinct hashes,
absent hash), a concurrent same-code login race, backup-code exhaustion, and a
disable-via-backup-code path.

* test(mfa): make the concurrent backup-code test deterministic

The race test relied on incidental scheduling to interleave the two requests,
so it could false-green if they happened to serialize. Add a barrier that holds
both requests just after verification (once both have read the same stored set)
until both arrive, then releases them into the atomic consume. This forces the
race every run, so the test fails against a non-atomic consume and passes only
when exactly one request wins. A timeout releases the barrier if only one
request arrives, so a setup fault fails loudly instead of hanging.
2026-05-31 20:28:48 -04:00
Anso d03d97d964 fix(nodes): close capability-gating gaps in node compatibility (#1261)
* fix(nodes): close capability-gating gaps in node compatibility

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two small parity fixes on the stats aggregates: upper-bound every current
window by `now` so a future-dated row (clock skew or a fixture) cannot inflate
the live counts, and order the new-ip pair scan so the sample actor shown in
the tile detail is stable. Adds a test asserting a future row is excluded.
2026-05-31 16:36:07 -04:00
Anso dbb7fe8215 feat(auto-heal): restart crashed containers and harden the heal loop (#1258)
* feat(auto-heal): restart crashed containers and harden the heal loop

Auto-Heal now restarts containers that crash (non-zero exit) and stay down
past the policy threshold, in addition to those that fail their Docker
healthcheck. Crash detection reuses the container event classifier so a
container that exits cleanly or that an operator stopped is never restarted;
only classified crashes set the heal signal.

Also hardens the existing loop:
- A paid controlling instance refreshes proxied remotes' entitlement on a
  background interval so a remote node's policies keep evaluating between
  operator visits instead of lapsing a few minutes after the sheet was last
  opened. A node that stays unreachable surfaces a warning.
- Overlapping policies (all-services plus a service-specific one) restart a
  given container at most once per evaluation pass, so the hourly cap holds.
- A failed restart now counts toward the cooldown and hourly cap, so a broken
  setup is retried on the cooldown interval rather than every poll.
- Diagnostic logging behind developer mode for evaluation, heal decisions,
  timing, and lease refresh.

* docs(auto-heal): document crash healing and refresh troubleshooting

Cover the two heal conditions (unhealthy and crashed), note that clean exits
and operator stops are never restarted and that crash healing acts on crashes
observed while Sencho is running, and update the troubleshooting and tab
visibility entries accordingly.

* fix(auto-heal): close stale crash-signal race and harden lease refresh

A crash signal could outlive the crash it described. The exit classifier is
deferred 500ms, so an immediate restart could let it stamp the crash marker
after the container was already running, and a later clean or operator-initiated
exit did not clear it; the next poll could then restart a container that had
exited cleanly. Now a clean or intentional exit always clears the marker, a die
that a start has superseded is not stamped, and the die's own time is captured at
arrival rather than at the deferred classification so the supersede check is
accurate.

Also:
- Crash state survives the event service's idle-prune window, so crash healing
  works for any configured threshold rather than only short ones.
- An exited or dead container is matched before any health-text parsing, so it
  can never fall into the healthcheck path.
- A remote with no reachable proxy target counts toward the lease-refresh
  failure warning instead of being silently skipped.
2026-05-31 16:00:38 -04:00
Anso ca346916c1 fix(auto-update): paid-gate execute route and harden image-check watchdog (#1257)
* fix(auto-update): paid-gate execute route and harden image-check watchdog

Auto-update execution is a paid capability, but POST /api/auto-update/execute
was reachable by any admin regardless of license. Add the paid guard so it
matches the rest of the surface (scheduled-task management and fleet refresh).
The scheduler dispatch to remote nodes still works because the controlling
instance forwards its tier with the request.

Restrict GET /api/image-updates/fleet to admins. The single-node status
endpoint that drives the sidebar update dot stays open to all roles.

Replace the image-check watchdog timer that released the run lock after five
minutes. On a healthy but slow scan it let a manual refresh start a second
concurrent check, duplicating notifications and racing the status writes. The
scan now owns its lock for its full duration, and every Docker socket and
filesystem read is bounded so a wedged daemon or mount cannot stall a scan
forever.

* test(auto-update): assert the debug skip-log branch in the image-check guard

The concurrency-guard test covered the warn branch for a trigger arriving past
the long-run threshold but never exercised the developer-mode debug skip log.
Add a case that enables developer mode and asserts the debug line fires for a
mid-scan trigger under the threshold.
2026-05-31 16:00:00 -04:00
Anso 7d4e61625f fix(notifications): harden alert dispatch crash-safety and redact webhook secrets in logs (#1255)
* fix(notifications): harden alert dispatch crash-safety and redact webhook secrets in logs

Make NotificationService.dispatchAlert never reject so the many
fire-and-forget callers (monitors, event streams, policy and image-update
paths) cannot trigger an unhandledRejection on an unhealthy database. The
whole dispatch body now sits inside a guard covering node resolution, the
history insert, channel-table reads, and the WebSocket broadcast; a
failure logs and drops the notification instead of crashing the process.
An inner guard still splits the write-success and write-failure metrics.

Also:
- Redact webhook URLs in diagnostic logs via a new maskWebhookUrl helper;
  Discord/Slack/custom webhook URLs embed their token in the path, so only
  the origin is safe to emit.
- Add error logging to four notification-history route handlers that
  previously swallowed database errors silently before returning 500.
- Snapshot the subscriber set before broadcasting so a close/error handler
  firing mid-send cannot mutate the set during iteration.
- Sanitize the admin-supplied route name in dispatch log lines.

Adds tests for dispatch crash-safety (write failure, post-write routing
failure, broadcast send failure), the success-path write metric, webhook
URL masking, and history-route error logging.

* fix(notifications): sanitize route name and patterns in create logs

Apply sanitizeForLog to the admin-supplied route name and stack patterns
in the route-creation log lines, matching the dispatch-site sanitization
and closing the remaining log-injection path on this feature.

Also extend tests: userinfo-stripping in maskWebhookUrl, subscriber-set
snapshot behavior under an unsubscribe-during-send, and error logging on
the mark-read, delete-one, and clear-all notification-history handlers.
2026-05-29 21:09:57 -04:00
Anso 69edb0dcbb fix(observability): gate global logs to admins, scope to managed containers, harden SSE (#1254)
* fix(observability): gate global logs to admins, scope to managed containers, harden SSE

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

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

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

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

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

Add these paths to HUB_ONLY_PREFIXES so hubOnlyGuard rejects a remote nodeId with
403 before the proxy runs, matching the existing protection on audit-log,
scheduled-tasks, and notification-routes. Add regression tests covering the
collection path, the SSE sub-path (both the x-node-id header and the ?nodeId=
query transport), and the stream-metrics endpoint.
2026-05-29 21:09:20 -04:00
Anso d41282e352 fix(blueprints): gate Federation pin control on admin role (#1252)
* fix(blueprints): gate Federation pin control on admin role

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Clear the templates list and Trivy availability at the start of each catalogue load so a failed fetch after a node switch shows the new node's empty state instead of the previous node's catalogue or scan toggle. Bound the developer-mode diagnostic template title/source length, and document that the registry cache serves the last-known-good catalogue on a transient fetch failure (the size cap still protects memory in every case).
2026-05-29 11:46:51 -04:00
Anso 2844f606cd fix(git-sources): harden webhook delivery, transport errors, and clone limits (#1249)
* fix(git-sources): harden webhook delivery, transport errors, and clone limits

Map webhook-pull outcomes to real HTTP status codes (200 success, 202
debounced, 404 no source, 422 failure) instead of always returning 200, so a
Git provider and any monitoring on it can tell when a delivery actually failed.

Close a concurrent webhook fan-out gap: the debounce window is now re-checked
inside the per-stack lock, so simultaneous deliveries for one push run a single
clone instead of one per request. The whole pull/apply critical section runs
under a single lock acquisition.

Unwrap fetch transport causes (ENOTFOUND, ECONNREFUSED, ECONNRESET, TLS) so a
clone failure surfaces an actionable, host-qualified message instead of a bare
"fetch failed".

Cap how many bytes a single clone may download to protect the host disk;
operators can tune it with GITSOURCE_MAX_CLONE_BYTES (default 100 MB).

Log webhook pull failures server-side, since the webhook path is unattended.

* test(git-sources): assert surfaced host via toContain to satisfy CodeQL

* fix(git-sources): bound per-file read, treat debounced webhooks as non-failure, correct clone-cap docs

* docs(git-sources): correct clone-cap comment to describe a download bound, not disk
2026-05-29 09:43:37 -04:00
Anso b33a0e8422 fix(deploy-enforcement): surface scan-policy blocks on update and sidebar deploys (#1248)
* fix(deploy-enforcement): surface scan-policy blocks on update and sidebar deploys

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

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

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

Addresses two gaps found in independent review:

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

Also list rollback as an enforced entry point in the docs and clarify that
Git Source enforcement covers both the create-time deploy and a manual
apply-with-deploy.
2026-05-29 08:48:50 -04:00
Anso 45844b92ca fix(atomic-deploy): harden rollback locking, restore fidelity, and tier gating (#1247)
* fix(atomic-deploy): harden rollback locking, restore fidelity, and tier gating

Hardens the Atomic Deployments feature found during a full audit:

- Rollback now holds the per-stack lifecycle lock (deploy/update already do),
  so a rollback can no longer race a concurrent deploy on the same compose
  files. Adds a 'rollback' lifecycle action and releases the lock in finally.
- restoreStackFiles is now a faithful revert: it removes managed compose/.env
  files added after the backup before copying, so a rollback no longer leaves a
  hybrid of old and new configuration. Scope is the protected file set only;
  user data is untouched. Aborts (rather than reporting success) if a stale
  managed file cannot be removed.
- The scheduled image-update path derives the atomic flag from the licence tier
  instead of hardcoding it on, keeping the paid capability explicit at the call
  site (the scheduler is already paid-gated; this prevents silent drift).
- The backup-metadata read (GET /stacks/:name/backup) now requires a paid
  licence, matching the rollback flow that is the only caller.
- Manual rollback dispatches a success/failure notification, alongside the
  existing audit-log entry.

Adds route integration tests (lock acquisition/release, tier 403, notifications,
no-backup 404), filesystem tests for the faithful restore (orphan removal,
variant switch, abort path, non-managed files preserved), a community-tier
scheduler test, and a developer-mode logging matrix. Documents the restore
semantics and reconciles the scheduled-update wording in the feature guide.

* fix(atomic-deploy): assert restore target stays within the compose dir before unlink

The orphan-removal step in restoreStackFiles joins the stack directory with a
managed filename and unlinks it. The stack directory is already validated and
contained by resolveStackDir (allowlist stack name + within-base assertion), but
the containment guard was not reapplied to the joined target at the delete sink,
so static analysis flagged the path as derived from user input. Reassert
containment on the final path before unlinking, matching the barrier the other
write/read helpers in this service already apply. No behavior change for valid
stacks; defense-in-depth at the sink.

* fix(atomic-deploy): inline the path-containment barrier at the restore unlink sink

The wrapped within-base assertion was not recognized as a sanitizer by the
static path-injection analysis, which still traced the stack name to the unlink
sink. Replace it with the inline path.resolve + startsWith containment check the
other write helpers in this service already use (the recognized barrier), kept
in the same scope as the sink. Behavior is unchanged for valid stack names.

* fix(atomic-deploy): clear stale managed files from the backup slot before writing

The backup directory is reused across runs and was only ever added to, never
cleared. A managed file removed from the stack since the last backup (e.g. a
deleted .env or a switched compose variant) lingered in the slot, so a later
rollback restored a file that did not exist immediately before the failed run,
contradicting the faithful-revert guarantee. Clear the protected file set from
the slot before copying the current files, with the same inline containment
barrier the restore path uses. A clear failure is logged, not fatal, since it
only risks a stale future rollback and should not block a valid deploy.
2026-05-29 00:12:39 -04:00
dependabot[bot] 1074d523b2 chore(deps): bump the all-npm-backend group in /backend with 4 updates (#1240)
Bumps the all-npm-backend group in /backend with 4 updates: [isomorphic-git](https://github.com/isomorphic-git/isomorphic-git), [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint), [@aws-sdk/client-ecr](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ecr) and [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3).


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

Updates `typescript-eslint` from 8.59.4 to 8.60.0
- [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.0/packages/typescript-eslint)

Updates `@aws-sdk/client-ecr` from 3.1053.0 to 3.1054.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.1054.0/clients/client-ecr)

Updates `@aws-sdk/client-s3` from 3.1053.0 to 3.1054.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.1054.0/clients/client-s3)

---
updated-dependencies:
- dependency-name: isomorphic-git
  dependency-version: 1.38.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: typescript-eslint
  dependency-version: 8.60.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.1054.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.1054.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-05-28 20:55:02 -04:00
Anso 5dea040ec8 fix(deploy-progress): decouple deploys from the live progress stream (#1246)
* fix(deploy-progress): decouple deploys from the live progress stream

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

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

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

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

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

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

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

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

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

Address review findings on the progress-stream routing:

- Only an id-less connectTerminal registration may become the id-less
  fallback socket. Previously every connectTerminal (including keyed deploy
  modals) set the fallback, so a headerless operation (bulk update, rollback,
  or a legacy client) resolved via getTerminalWs() into another user's keyed
  deploy modal. Keyed sockets are now excluded from the fallback, and a socket
  that adopts a session id is removed from it.
- The connect-timeout fallback now also flags the modal as "Live progress
  unavailable" instead of leaving it on "Connecting..." while the deploy runs.
- Log only a short prefix of the deploy session id in developer diagnostics,
  not the full capability value.
2026-05-28 20:51:45 -04:00