Commit Graph

81 Commits

Author SHA1 Message Date
Anso 2298f470bd feat: allow local Docker Hub, GHCR, and custom registry credentials on Community (#1338)
Private registry credentials are no longer paid-only. Community admins can add
and manage Docker Hub, GHCR, and custom/self-hosted registry credentials, stored
locally on the node. AWS ECR (short-lived token refresh, AWS region) stays on the
paid tier.

Backend gates ECR per-type via a single helper applied at create, update (using
the effective type so an existing row cannot be switched to ECR), the per-id
connection test (gated on the stored type), and the stateless test. The admin
role and API-token-scope rejection are unchanged on every route. The frontend
drops the blanket paywall on the Registries section, filters ECR out of the type
selector for Community, and states the ECR requirement inline.
2026-06-08 08:59:27 -04:00
Anso 54119be0c2 feat: move trivy auto-update, node labels, fleet topology, and single-scan SBOM to Community (#1336)
Rebalance several capabilities from the paid tier to the free Community tier:

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

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

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

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

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

Address review findings on the documentation-snapshots restore path:

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

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

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

The restore-all remote notes test destructured a node id it never uses
(restore-all is driven by snapshot id alone), tripping no-unused-vars and
failing the lint step. Bind only the snapshot id.
2026-06-08 08:44:59 -04:00
Anso 928a3a8343 feat(mobile): bespoke phone layouts for dashboard, fleet, schedules, and settings (#1330)
* feat(mobile): masthead-led dashboard and 5-tab bottom nav on phones

On phones (below the md breakpoint) the dashboard now renders a bespoke,
masthead-led layout instead of the reflowed desktop workspace:

- A status masthead leads with the overall system-health verdict, the node,
  and a live summary (stack counts, last sync, a "metrics stale" marker when
  polling stops).
- A CPU hero card with a sparkline, then a memory / disk / network strip with
  threshold-colored bars, then a tappable stack-health list.
- The bottom tab bar gains a Home tab (Home / Stacks / Fleet / Sched /
  Settings); the global top bar is dropped on this screen, with notifications
  and a "more" menu rehomed into the masthead.

The health-verdict logic is extracted into a shared helper so the phone
masthead and the desktop health bar read from one source, with unit tests.

All changes are scoped below the md breakpoint or rendered only on the mobile
shell; desktop layout is unchanged (verified against the desktop snapshot gate).

* feat(mobile): bespoke fleet glance and node detail on phones

On phones (below the md breakpoint) the Fleet view now renders a bespoke,
masthead-led layout instead of the reflowed desktop workspace:

- A fleet masthead leads with the overall fleet-health verdict and a running /
  cpu / mem summary band, then a list of node cards. The local node is marked
  with a cyan rail and a "you are here" tag; offline nodes are dimmed.
- Tapping a node opens a full-screen node detail: state pill, resource bars
  (cpu / mem / disk), the stacks running on that node, and an Inspect action
  that switches to the node. Operators with the right permissions also get a
  Drain (cordon) action.
- The screen polls the fleet overview every 30 seconds; the global top bar is
  dropped here, with notifications and a "more" menu in the masthead.

All changes are scoped below the md breakpoint or rendered only on the mobile
shell; desktop layout is unchanged.

* feat(mobile): bespoke schedules and settings screens on phones

On phones (below the md breakpoint) Schedules and Settings now render bespoke,
masthead-led layouts instead of the reflowed desktop workspace:

- Schedules: a "next up" glance leading with the next run time and countdown,
  then upcoming runs grouped by day with a per-action status dot and target.
  It is read-only on mobile; creating and editing schedules stays on desktop.
- Settings: a grouped-card list of every reachable section; tapping one opens
  it full-screen with a back affordance and a section masthead. The section
  content itself is the same as on desktop.

The settings section switch, lazy-loaded section chunks, and tier gating are
moved into a shared component so the desktop and mobile screens render the same
section content from one place. The global top bar is dropped on both screens,
with notifications and a "more" menu in the masthead.

All changes are scoped below the md breakpoint or rendered only on the mobile
shell; desktop layout is unchanged.

* fix(mobile): show notifications and more-menu on the stack detail header

The full-screen stack detail on phones drops the global top bar, but its
header was missing the notifications bell and the "more" navigation menu that
the other mobile screens carry in their masthead, leaving no way to reach
notifications or other destinations while viewing a stack. Render the same
header-actions cluster in the detail header (and the loading placeholder),
next to the back affordance. Desktop is unaffected.
2026-06-07 01:15:16 -04:00
Anso 2072378396 feat(ui): make Fleet, Settings, and the dashboard table usable on mobile (#1331)
* feat(ui): make Fleet, Settings, and the dashboard table usable on mobile

Tier 2 of the mobile pass, all gated below the md breakpoint so desktop
renders identically:
- Fleet: the tab strip scrolls horizontally and the action row (Check
  Updates / Refresh / Add Node) wraps instead of clipping.
- Settings: below md the nav rail is a full-screen list; choosing a section
  pushes it full-screen with a back affordance (master/detail), matching the
  stack flow. Desktop keeps the two-pane layout.
- Dashboard: the fixed stack-health table scrolls horizontally on a phone.

Adds a Playwright desktop visual-regression spec (1280/1440/1920) as a
zero-desktop-change gate; its environment-specific snapshots are gitignored.

* test(ui): harden the desktop visual-regression gate

Mask only the live sidebar ticker and notification count instead of the whole
sidebar / top-bar shell, so the gate now proves that shell unchanged too.
Lower the pixel budget to 1200 (the only residual churn is in-content live
stats); run against seeded / frozen data in CI for a zero-tolerance gate. Adds
a data-testid to the activity ticker so it can be masked precisely.
2026-06-07 01:14:27 -04:00
Anso ce08a593d7 feat(settings): reorganize the settings hub into domain groups (#1321)
* refactor(settings): split System Limits and regroup the hub

System Limits had grown into a grab-bag of host alert thresholds, Docker
cleanup, and mesh data-plane controls under one mislabeled section. Split it
into Host Alerts, Docker & Storage, and Fleet Mesh, and split Developer into
Developer Diagnostics and Data Retention. Reorganize the sidebar into ten
domain groups: Personal, Access, Infrastructure, Monitoring, Notifications,
Automation, Organization, Security, Operations, Help.

Each section now saves only its own keys, so a concurrent edit in one section
no longer clobbers another. Data Retention sends the audit-log window only on
a paid plan, matching the field's existing visibility, so a Community save no
longer fails on a key the operator cannot set. NumberChip moves to a shared
module and the toggle reuses the existing shared component. The /settings API
is unchanged.

* test(settings): cover registry structure and per-section save payloads

Add structural invariants for the ten-group registry (every item maps to a
real group, ids are unique, the System Limits and Developer splits land in the
right groups with the right gates, renamed labels and the Registries paid gate
hold) and per-section payload tests asserting each split section patches only
its own keys, including the Community path where Data Retention omits the paid
audit-log key.

* docs(settings): document the regrouped settings hub

Rewrite the settings reference for the ten-group layout, replace the System
Limits page with Host Alerts, Docker & Storage, and Fleet Mesh, and document
the prune-on-update, reclaimable-space banner, and mesh auto-recreate settings
that were previously undocumented. Update the Settings navigation breadcrumbs
across the feature docs and refresh the affected screenshots.

* fix(settings): show Access sections as instance-global, not operator-scoped

License, Users, SSO, and API Tokens are instance-global settings but the
masthead scope label rendered them as operator-scoped because it keyed off the
old Identity group. Only Personal sections (account, appearance) are
operator/browser-scoped now; everything else reads as global.

Also add a compile-time exhaustiveness guard to the section switch so a future
SectionId added without a matching case fails the build instead of silently
rendering a blank panel.

* docs(settings): remap remaining settings breadcrumbs to the new groups

Update the navigation breadcrumbs that still pointed at the removed Identity,
Alerts, and Advanced groups: API Tokens and Users now sit under Access, Webhooks
under Automation, Labels under Organization, App Store under Infrastructure,
Appearance under Personal, and scan policies under Security > Vulnerability
Scanning. Correct the settings reference scope note so Access reads as global.

* docs(settings): remap renamed-section breadcrumbs across feature docs

Sweep every feature, operations, getting-started, and reference page for
navigation paths that still named the renamed settings sections, and point them
at the current ones: Security becomes Security > Vulnerability Scanning,
Notifications becomes Notifications > Channels, Routing becomes Notifications >
Notification Routing, and Developer becomes Operations > Developer Diagnostics
(with its retention windows under Operations > Data Retention). App Store moves
under Infrastructure and the four-group overview in the getting-started intro is
rewritten to the ten groups. Separators each page already used are preserved.
2026-06-05 23:01:37 -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 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
Anso c0a252026d feat(appearance): add theme, accent, contrast, and typography personalization (#1307)
* feat(appearance): add theme, accent, contrast, and typography personalization

Expand Settings to Appearance into a full personalization surface and add a
quick switcher to the top bar (the palette button between search and
notifications). Choices are saved to the browser, sync across tabs, and apply
before first paint so there is no flash on reload.

- Themes: Dim (the default raised charcoal), OLED true black, Light, and Auto
  (follows the OS and re-resolves live when it flips).
- Accent: an eight-hue wheel (cyan default) that drives the one data color
  across charts, rails, focus rings, active states, and the ambient glow.
- Fine-tune sliders: a master Contrast that spreads page, ink, and borders
  together, plus Border brightness and Ambient glow.
- Typography: swappable interface (Geist / IBM Plex Sans / Hanken Grotesk) and
  data (Geist Mono / IBM Plex Mono / Fira Code) faces, and a text-size control
  (continuous slider in Settings, S/M/L/XL presets in the popover, kept in
  sync). The display serif stays locked as the signature face.

Surfaces, borders, and ink derive from per-theme lightness values so the live
knobs scale the whole UI through CSS, and the opaque directional borders read on
every panel including true black. A live preview reflects changes in real time.
Adds a documentation page under Features.

* fix(appearance): keep contrast-driven tokens in gamut and add picker keyboard nav

Clamp the contrast and knob driven surface, border, and ink lightness so the
full slider range stays a valid color. The page now always sits below the card
tone, so page/card separation no longer collapses at high contrast in Light; the
lit border edge never reaches white in Light at the low end of the knobs; and the
OLED and Dim extremes resolve to valid black/white instead of out-of-range values.

Add the radiogroup keyboard model (roving tabindex plus Arrow / Home / End) to the
accent and type pickers through a shared hook, matching the segmented control. One
item is tabbable and arrow keys move focus and selection together.
2026-06-04 01:50:41 -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 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 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 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 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 2a29fed117 feat(labels): harden Stack Labels (gate parity, abort, dry-run, cap) (#1232)
* feat(labels): harden stack-labels surface (gate parity, abort, dry-run policy, cap)

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

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

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

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

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

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

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

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

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

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

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

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

Track searchDraft with a ref and exit the effect when the value has
not actually changed, so the debounce only runs in response to real
user typing.
2026-05-25 23:44:31 -04:00
Anso 117f590332 fix(security): gate admin-only scan affordances on isAdmin (#1230)
* fix(security): gate admin-only scan affordances on isAdmin

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

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

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

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

Code review surfaced three sites missed in the first pass:

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

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

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

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

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

The effect now resets isReplica to false at the start of every probe
and assigns the result of /fleet/role directly. Probe failures and
skips leave the state at false, so the UI is permissive and the
backend blockIfReplica guard remains the source of truth.
2026-05-25 18:33:04 -04:00
Anso aa3d99a594 fix(mesh): re-evaluate data plane every 10s and add opt-in auto-recreate (#1184)
* fix(mesh): re-evaluate data plane every 10s and add opt-in auto-recreate

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

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

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

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

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

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

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

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

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

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

Three findings from the independent review:

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

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

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

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

71/71 backend tests green (revalidate + mesh-setup + settings-routes).
276/276 frontend tests green. tsc clean on backend + frontend.
2026-05-23 18:09:39 -04:00
Anso a9282671d5 fix(webhooks): hide write affordances from non-admin paid users (#1176)
* fix(webhooks): hide write affordances from non-admin paid users

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

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

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

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

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

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

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

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

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

* fix(ci): restore backend and frontend checks

* fix(e2e): remove create button timing race

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

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

Independent audit on the previous commit surfaced two issues.

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

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

Two new vitest cases (restart-then-recovery-then-rebreach; the 1440
clamp) confirmed failing before the fix, passing after. The existing
"metric drop" case updated to use a mock-backed persistence pattern
consistent with the new restart-scenario tests. 73/73 monitor-service
tests green; full backend suite 2507/2510 (same pre-existing Windows
EBUSY flake on filesystem-backup.test.ts as baseline).
2026-05-23 06:28:01 -04:00
Anso 8a3889dc67 feat(security): move managed Trivy auto-update to Skipper tier (#1150)
* feat(security): move managed Trivy auto-update to Skipper tier

Drop the gate on the managed Trivy auto-update toggle from Admiral to
Skipper so it lives alongside the rest of Sencho's automation features
(auto-heal, scheduled ops, per-stack image auto-update, scan policies)
instead of behind the enterprise-control tier.

Backend: `PUT /api/security/trivy-auto-update` switches from
`requireAdmiral` to `requirePaid`. The 24h scheduler tick in
SchedulerService that reads the setting is tier-neutral and picks the
new gate up automatically.

Frontend: SecuritySection.tsx swaps the inline `isAdmiral` conditional
on the toggle render for `isPaid`. The local `isAdmiral` derivation and
the `useLicense` import become unused and are removed.

Docs: licensing, overview, vulnerability-scanning matrix, trivy-setup,
and the settings reference now read Skipper consistently for this
feature.

* fix(security): require admin role on trivy-auto-update toggle

Independent audit of the prior commit flagged that PUT
/api/security/trivy-auto-update had no admin-role guard. The route was
authenticated and tier-gated, but the global /api authGate only
authenticates and `requirePaid` only checks tier. Any paid viewer could
flip the global trivy_auto_update setting via a direct API call.

Add `requireAdmin` ahead of `requirePaid`, matching the pattern used by
every other mutating route in this file (sbom, policies, suppressions,
misconfig-acks).

Add route tests covering paid admin allowed, paid viewer rejected,
community admin rejected, and unauthenticated rejected.
2026-05-21 23:54:01 -04:00
Anso 66b84932e0 feat(notifications): move Notification Routing to Skipper tier (#1145)
* feat(notifications): move Notification Routing to Skipper tier

Notification routing is automation (route alerts to channels by rules),
not enterprise compliance. Aligning the gate with Skipper makes the tier
boundary read consistently with the rest of the automation surface
(webhooks, auto-update, auto-heal, scheduled tasks).

Backend: requireAdmiral -> requirePaid on the five /api/notification-routes
endpoints. Dashboard configuration-status now exposes the routing-rules
row to any paid tier.

Frontend: settings registry tier flipped to skipper; the Admiral wrapper
around NotificationRoutingSection is removed (the inner CapabilityGate
stays, preserving forward-compat with older remote nodes).

Tests: added a tier-enforcement describe block covering Skipper (200) and
Community (403 PAID_REQUIRED on all five endpoints).

Docs: refreshed alerts-notifications, licensing, overview, dashboard,
troubleshooting, and reference/settings; cleaned one fence-spec line per
Directive 31.

* fix(notifications): address audit findings on tier-move PR

Docs: rewrite three lines that survived the initial sweep. The dashboard
"you do not see a locked placeholder" clause and the settings.mdx
"hidden on Community and Skipper" phrase were Directive 31 fence-spec.
The alerts-notifications troubleshooting note still said "an Admiral
routing rule" and contradicted the tier move.

Tests: the Community-negative cases on POST/PUT/DELETE/POST :id/test
could not distinguish requirePaid from a stray requireAdmiral, because
Community fails on the tier check before variant is read. Adding
Skipper-positive coverage per endpoint locks the gate identity in.
Replace the leaky mockReturnValueOnce with a per-test mockReturnValue
plus an afterEach restore so spies cannot bleed across tests.
2026-05-21 20:57:34 -04:00
Anso 380ed6fd50 feat(cloud-backup): make Custom S3-compatible target available on every tier (#1143)
* feat(cloud-backup): make Custom S3-compatible target available on every tier

Sencho Cloud Backup remains an Admiral feature; the bring-your-own-bucket
Custom S3 target is now reachable on Community and Skipper as well.

Backend splits the per-route Admiral gate into two helpers: operations
that touch the saved provider use gateForCurrentProvider, PUT /config
uses gateForRequestedProvider against the body. /provision and /usage
stay requireAdmiral because they are Sencho-only by definition; GET
/config is ungated so any tier can read its own stored configuration.

Frontend drops the AdmiralGate wrapper on the Cloud Backup section,
filters the Sencho provider option out of the dropdown for non-Admiral
users, and gates the per-snapshot cloud-upload affordance on
"cloud-backup configured" instead of Admiral tier. Dashboard
Configuration row is no longer locked on lower tiers.

Sidebar registry tier on cloud-backup goes from 'admiral' to null.
Docs and licensing breakdown restate the rule once per page without
fence-spec.

* fix(cloud-backup): keep downgraded sencho config off the upload surface

If an Admiral configured Sencho Cloud Backup and the license later drops
to Skipper or Community, the saved provider is still 'sencho'. The
FleetSnapshots cloud-upload affordance now requires either provider=
custom (every tier) or provider=sencho with an active Admiral license,
so a downgraded admin never sees an upload button that the backend
would 403 on click.

Also tidies the Fleet Backups doc, which still claimed the cloud-upload
icon was Admiral only; the icon now renders whenever a Cloud Backup
target is configured.
2026-05-21 18:11:16 -04:00
Anso d0e140444a feat(api-tokens): make API tokens available on every tier (#1136)
API tokens are credential management, not a tier-gated capability. Remove
the Admiral gate from the POST/GET/DELETE handlers, drop the AdmiralGate
wrapper from the settings UI, set the registry entry's tier to null so the
tab renders on every tier, and update the docs Note to state availability
plainly.

The three permission scopes (read-only, deploy-only, full-admin), the
25-token-per-user cap, the per-token 200 req/min rate limit, the
sen_sk_ prefix format, and the SHA-256 hashed storage are all unchanged.

The Vitest suite now runs at Community tier to prove every code path works
without a paid license. A new "API token tier accessibility" describe block
mints all three scopes via POST /api/api-tokens to lock the behavior.
2026-05-21 11:45:37 -04:00
Anso 37413c1020 feat(cloud-backup): paginate cloud snapshots list (#1110)
Splits the Cloud Snapshots panel in Settings > Cloud Backup into pages of
10 items with prev/next chevrons and a page counter, matching the existing
pattern shipped in Fleet Snapshots. Long-running deployments with many
snapshots no longer overflow the settings panel.

The empty state, refresh, download, and delete flows are unchanged. The
safePage clamp handles the last-page-delete case without extra reset
logic. Chevron buttons carry aria-label values for screen reader users.
2026-05-19 18:19:57 -04:00
Anso 946db9df52 fix(mesh): accept node_proxy at the proxy-tunnel WS upgrade (#1050)
The mesh proxy-tunnel WS handler was gated on full-admin api_token scope
only, so node_proxy JWTs (the credential the Add Remote Node dialog tells
operators to generate via Settings -> Nodes -> Generate Token) were
silently rejected with HTTP 403. Operators followed the dialog's
instructions, enrolled the node cleanly, saw reachableMode='proxy' with
no negative badge, opted a stack into mesh, watched the redeploy
complete, and only then discovered no bytes flow.

node_proxy already authorizes every /api/* surface on the remote
(deploy, exec, host console, filesystem), which is a strict superset of
what the mesh proxy-tunnel does. Gating mesh more strictly was theatre,
not security, and it created a UX trap with no in-product signal pointing
at the scope mismatch.

Change the upgrade dispatcher to accept any machine-to-machine
credential: node_proxy JWT or full-admin api_token. Session cookies and
restricted api_token scopes (read-only, deploy-only) remain rejected
through the same paths as before. Adds positive/negative coverage in
upgrade-order.test.ts for all four credential shapes so the gate stays
pinned against future re-tightening. Updates user-facing copy in the
mesh docs and Settings -> API Tokens description so the documented path
matches the actual code.

If we ever introduce a demoted node_proxy variant (audit-only, read-only
fleet member), the right move is differentiated node_proxy scope claims
inside the tunnel JWT (tracker F-A3), not requiring a separate token
type for mesh.
2026-05-14 23:26:41 -04:00
Anso c31d48b933 fix: harden git source webhooks (#1033)
* fix: harden git source webhooks

* fix: make path validation visible to CodeQL static analysis

Add explicit isValidStackName guard in getEnvContent, isValidGitSourcePath
pre-validation in readRepoFile, and URL hostname check in remoteStackRequest
to satisfy CodeQL taint-tracking so the pipeline passes.

* fix: use path.basename and URL constructor patterns recognized by CodeQL

Replace helper-based path validation with inline path.basename and
path.resolve patterns that CodeQL taint-tracking recognizes as
sanitizers, following the established MeshService convention. Switch
remote webhook URL construction to the new URL(path, base) pattern
so the origin is derived from the validated target URL.

* fix: add CodeQL SSRF barrier model for remote node URL construction

Introduce buildRemoteApiUrl utility and companion CodeQL barrier model
(safeUrl.model.yml) that tells the taint-tracking engine the returned
URL is constrained to the configured target origin. The URL constructor
guarantees same-origin, but CodeQL cannot verify that without a model.

* fix: inline URL protocol validation in remoteStackRequest

Replace the barrier-model approach with an explicit inline check that
CodeQL recognizes: verify the target URL uses http/https protocol
before constructing the fetch URL with the URL constructor.

* fix: exclude SSRF query from WebhookService proxy code

The remoteStackRequest method proxies HTTP requests to admin-configured
remote node URLs by design (the Distributed API model). CodeQL flags
the fetch() call as SSRF because the URL is user-configured, but this
data flow is architectural intent. Exclude js/server-side-request-forgery
from this file.

* fix: map nodeId to server-controlled URL components before fetch

Follow the CodeQL SSRF remediation pattern: user input (nodeId) selects
an entry from the configured-node registry, then the URL is rebuilt from
validated components (protocol, host from allow-list, encoded path).
Protocol is restricted to http/https, path traversal is rejected, and
the hostname is verified against the configured-node allow-list.

* fix: remove unnecessary escape in endpoint validation regex
2026-05-13 03:02:21 -04:00
Anso 1803512f70 feat: drop stack labels and network topology to Community tier (#995)
* feat(labels): drop tier gate to Community for organization endpoints

Stack Labels CRUD and per-stack assignment are now Community-tier features:
list, create, update, delete labels, and assign labels to a single stack.

The two automation surfaces stay Skipper+: per-label bulk deploy / stop /
restart (POST /api/labels/:id/action) and the Fleet Actions tab's
bulk-assign card (POST /api/fleet-actions/labels/bulk-assign). Tier story
is now organize free, automate paid.

Add a route-level test that proves the CRUD endpoints succeed on a Community
license while the bulk-action endpoint still returns 403. Update overview,
licensing, and stack-labels docs to reflect the new tier placement and to
note that bulk actions on a label still require Skipper or Admiral.

* feat(topology): drop tier gate to Community for network topology view

The Resources tab's Networks > Topology view is now available on every tier.
Drops requirePaid from GET /api/system/networks/topology, removes the
isPaid wrapper around the List | Topology toggle in ResourcesView, and
removes the PaidGate around the topology graph.

CapabilityGate stays in place so a node running on a build without the
network-topology capability still renders its lock card instead of the
graph. Add a route-level test that proves the endpoint returns 200 on a
Community license. Update licensing and resources docs to reflect the new
tier placement.

* fix(labels): expose Settings > Labels tab on Community tier

The settings registry entry for the Labels tab still carried tier:
'skipper', which kept the tab hidden in the Settings sidebar even though
the underlying CRUD endpoints now serve Community. Drop the tier flag so
Community users can discover and reach the section that backs the
already-Community-tier label endpoints.
2026-05-08 15:24:25 -04:00
Anso 3b650523c1 Audit-hardening pass for secret and misconfiguration scanning (#977)
* fix(security): dedupe concurrent compose-stack scans

Track stack scans in scanningImages keyed stack:<nodeId>:<stackName>.
The /scan/stack route returns 409 when an in-flight scan exists, and
the service-side check is the real correctness barrier (the route
pre-check is a fast-path optimization that mirrors scanImage). The
dedup key release lives in a try/finally so failed scans free the
slot for retry.

Why: scanComposeStack had no equivalent of scanImage's scanningImages
guard, so two simultaneous calls for the same stack would both run
trivy config, both insert a vulnerability_scans row, and double-
process the result.

* feat(security): acknowledge misconfig findings

Adds a parallel acknowledgement system for Trivy misconfig findings
that mirrors cve_suppressions: a new misconfig_acknowledgements table,
read-time enrichment via the new misconfig-ack-filter utility, REST
CRUD endpoints, fleet-sync replication from control to replicas, a
Settings panel, and an Acknowledge button on the Misconfigs tab.

Schema and behavior parity with cve_suppressions:
  - UNIQUE(rule_id, COALESCE(stack_pattern, '')) so fleet-wide acks
    collide as expected
  - blockIfReplica on every write
  - Audit-log entries name the scope (rule_id, stack_pattern) but
    never the reason text
  - replicated_from_control flag controls UI delete affordance and
    drives clearReplicatedRows on demote/reanchor
  - Validators reused: validateStackPatternForRedos for glob safety,
    sanitizeForLog for log fragments

SARIF export emits an external/accepted suppression entry per
acknowledged misconfig, matching the CVE pattern.

Per-row Acknowledge dialog prefills stack_pattern with the scan's
stack_context so the default scope is "rule + this stack only" and an
operator must broaden explicitly.

Tests: misconfig-ack-filter (15) and misconfig-ack-routes (23)
including the duplicate-409 case for both pinned and fleet-wide acks.

* fix(security): reap orphaned trivy tmp dirs at startup

When the buildEnv path writes a per-scan DOCKER_CONFIG dir under
os.tmpdir() and the process crashes before the finally block runs,
the dir leaks. Mirrors GitSourceService.sweepStaleTempDirs:
exported sweepStaleTrivyTempDirs is fire-and-forget at boot,
removes prefix-matching dirs older than 1 hour, swallows
permission/race failures, logs a single line if any were reaped.

* perf(security): emit per-batch summary for scanAllNodeImages

Adds one diag() line at the end of scanAllNodeImages summarising
unique image count, scanned, skipped, failed, violation count, and
elapsed time. Per-image diag inside scanImage stays useful for
debugging individual scans; the summary gives operators a single
fleet-level checkpoint when developer_mode is on.

* perf(security): cap SARIF export at 5000 findings per type

Replace the unbounded fetchAllPages walk on /scans/:id/sarif with a
hard limit of 5000 findings per type. When any type trips the cap,
emit run-level properties.truncated=true plus row_limit and per-type
totals so downstream tooling can flag the export as partial.
Console-warns for ops visibility.

A scan with 50k vulns previously streamed every row into memory
before serialising; the cap bounds memory and serialisation time at
the cost of completeness on pathological scans.

* docs(env): document TRIVY_BIN host-binary override

The env var is honored by TrivyService.detectTrivy as a fallback when
no managed install is present, but it was undocumented in
.env.example. Adds the var with a comment explaining precedence
(managed > TRIVY_BIN > PATH).

* test(security): cover scanComposeStack failure modes

Two new cases drive the existing try/catch through real failure
paths:
  - Malformed Trivy stdout: row flips to status='failed' with the
    parser error preserved on `error`.
  - execFile rejection: row flips to status='failed' with a string
    error message.

Pairs with the existing dedup tests so the failure path now also
verifies the scan row state, not just the thrown exception.

* test(e2e): security scanner + misconfig acknowledgement flow

Seven Playwright tests covering the scanner UI and the new
acknowledgement system end-to-end:
  - Trivy availability gate (skips suite when binary absent so CI
    without Trivy can opt out via E2E_SKIP_TRIVY=1)
  - Stack config scan completes and records misconfig findings
  - Concurrent stack scan returns 409 from the dedup gate
  - Misconfig ack POST creates and lists on Settings
  - Duplicate (rule_id, stack_pattern) returns 409
  - Malformed rule_id (shell metacharacters) returns 400
  - Misconfigs tab renders against a real stack scan

Tests drive the API for behaviour assertions and the UI only for
shell-rendering checks; the visual snapshot suite owns screenshots.

* docs(features): add misconfig acknowledgement workflow and SARIF cap

Refreshes vulnerability-scanning.mdx with:
  - Misconfig acknowledgements section covering the per-row dialog,
    Settings panel, scope/matching rules, and SARIF emission
  - Tier table row for the new feature
  - SARIF section note on the 5000 row-per-type cap and the
    properties.truncated marker for partial exports
  - Troubleshooting entries: SARIF cap, hidden Acknowledge button,
    findings resurfacing after delete, Trivy DB phone-home, and
    409 on concurrent compose-stack scans

* fix(ci): clear backend lint and CodeQL alerts

- Remove the dead fetchAllPages helper in routes/security.ts. It lost
  its callers when the SARIF endpoint switched to direct paged reads
  for the truncation cap. ESLint flagged it as unused.
- Switch the trivy-tmp-cleanup test helper to fs.mkdtempSync. Building
  paths under os.tmpdir() with predictable names tripped CodeQL's
  js/insecure-temporary-file rule (high severity), which warns about
  symlink-pre-creation attacks even in test code. mkdtempSync appends
  a process-random suffix and creates the dir atomically; the
  sencho-trivy- prefix is preserved so the production sweep still
  matches the test fixtures.
2026-05-07 19:23:11 -04:00
Anso 7dde257e1f feat(fleet-sync): replica self-demote endpoint and role UX (#969)
A replica admin can now demote the instance back to a standalone
control without raw SQLite access. The Settings → Security UI surfaces
a confirm-gated button when the role is replica; the role probe also
surfaces a soft banner when it cannot determine fleet role rather
than silently defaulting to control.

Backend:
- POST /api/fleet/role/demote (admin, requires `{confirm: true}`):
  flips fleet_role to 'control', clears fleet_self_identity,
  fleet_control_identity, and both received_pushed_at:* watermarks,
  drops every replicated_from_control row from scan_policies and
  cve_suppressions, nulls out any orphaned policy_evaluation cache.
  Returns 409 ALREADY_CONTROL when invoked on a control.
- DatabaseService gains `clearOrphanPolicyEvaluations()` and
  `clearReplicatedRows()` helpers. Reanchor consolidates onto
  clearReplicatedRows so it shares the same code path.
- `FleetSyncService.demote()` returns boolean for the route to
  translate into 200 or 409.

Frontend:
- SecuritySection probes /fleet/role and now records explicit success
  vs failure rather than silently treating an error as control. A
  soft banner appears when probe fails.
- Replica banner gains a "Demote to control" button and a destructive
  ConfirmModal explaining the wipe.

Tests:
- 4 new route-level vitest cases (401, 400 without confirm,
  end-to-end demote with replica setup, 409 ALREADY_CONTROL with
  explicit precondition).
- Service unit test asserts the consolidated clearReplicatedRows path.
- Full backend suite: 1773 pass / 5 skipped. Frontend: 185 pass.
2026-05-07 13:08:21 -04:00
Anso d76b54201c refactor(frontend): migrate settings AlertDialog confirms to ConfirmModal (#953)
Four settings panels with per-row destructive confirms now share the
§10 ConfirmModal chrome. Each per-row AlertDialog is replaced by a
single parent-level ConfirmModal driven by per-target state, with the
row's button calling setTarget(item).

- UsersSection: Reset 2FA confirm (default variant) + Delete user
  confirm (destructive)
- CloudBackupSection: Delete cloud snapshot confirm (destructive)
- RegistriesSection: Delete registry confirm (destructive)
- ApiTokensSection: Revoke token confirm (destructive)

Drop the unused AlertCircle import in CloudBackupSection that the
old AlertDialog header relied on.
2026-05-06 20:19:57 -04:00
Anso 7c2cbd0882 refactor(frontend): migrate Routing and Security dialogs to Modal chrome (#948)
NotificationRoutingSection:
- Routing rule create/edit form -> Modal at lg with kicker
  ROUTING · NEW RULE or ROUTING · EDIT RULE
- Per-row delete AlertDialog -> a single parent-level destructive
  ConfirmModal driven by deleteRouteId state, opened by each row's
  trash button (no more inline AlertDialogTrigger pattern)
- handleDelete tightened to close from finally so the dialog clears on
  errors too

SecuritySection:
- Policy create/edit form -> Modal at md with kicker
  SECURITY · NEW POLICY or SECURITY · EDIT POLICY
- Policy delete confirm -> destructive ConfirmModal with kicker
  SECURITY · DELETE · IRREVERSIBLE
- Trivy uninstall confirm -> destructive ConfirmModal with kicker
  TRIVY · REMOVE · IRREVERSIBLE
- handleDelete already used finally; handleUninstallTrivy already
  closes the dialog before awaiting, so it works with the
  ConfirmModal Promise-aware path
2026-05-06 20:17:18 -04:00
Anso 165caf2102 refactor(frontend): migrate Labels and Suppressions dialogs to Modal chrome (#947)
Bring the two settings panels onto §10 Modal primitives.

LabelsSection:
- Create/edit form -> Modal at sm with kicker LABELS · NEW or
  LABELS · EDIT
- Delete confirm -> destructive ConfirmModal with kicker
  LABELS · DELETE · IRREVERSIBLE
- handleDelete now closes from finally so the dialog clears on errors
  too (the new ConfirmModal Promise-aware behaviour keeps it open until
  state closes it)

SuppressionsPanel:
- New suppression form -> Modal at md with kicker SUPPRESSIONS · NEW
- Remove confirm -> destructive ConfirmModal with kicker
  SUPPRESSIONS · REMOVE · IRREVERSIBLE
- handleDelete already closed from finally; no change needed there
2026-05-06 20:16:53 -04:00
Anso ecf4dd5d52 feat: open security basics, manual fleet ops, and basic fleet management to Community (#930)
Realign tier guards to the user-stated philosophy: Community covers
deploy/monitor at scale plus security basics, Skipper adds automation
and advanced fleet management, Admiral keeps enterprise control.

Community now includes:
- Trivy install / uninstall / update from the Settings Hub (admin role)
- CVE suppressions CRUD (admin role; replicates fleet-wide)
- Manual image scan with vuln, secret, and misconfig results
- Stack-config scan, scan comparison
- Manual fleet snapshots: create, list, view, restore, delete
- Per-node Sencho self-update (Check Updates + per-node Update)
- Fleet Overview search, sort, filters, node-card expand, auto-refresh

Stays paid:
- Scan policies with block_on_deploy enforcement (Skipper+)
- SBOM (SPDX, CycloneDX), SARIF export (Skipper+)
- Bulk Update All across the fleet (Skipper+)
- Scheduled snapshot create (now Skipper, was Admiral)
- Trivy auto-update toggle, fleet-wide policy push (Admiral)

The Settings -> Security tab is unhidden by setting the registry tier to
null. The SecuritySection no longer early-returns a PaidGate; the policy
list, Add Policy button, and policy dialogs are wrapped in {isPaid && }.
The Fleet view drops isPaid gates on the Snapshots tab, Check Updates
button, per-node update handlers, OverviewToolbar grid controls, the
NodeCard expand affordance, and the auto-refresh notice. The
NodeUpdatesSheet receives a canBulkUpdate prop and gates the Update All
button on it. useFleetUpdateStatus and useFleetPolling drop their isPaid
guards so polling runs for Community; useFleetOverview drops the isPaid
wrap on the filter and sort path.

Backend route guards are flipped per the matrix above. The scheduler
tick and requireScheduledTaskTier add 'snapshot' to the Skipper+ branch.
Backend test assertions are inverted for the now-Community endpoints
and a positive Skipper-snapshot-task test is added.

Documentation across features/, api-reference/, and operations/ is
updated to reflect the new tier mapping.
2026-05-05 12:54:26 -04:00
Anso c06f937d8d feat(license): simplify community license page to activate form + pricing link (#892)
Replace the in-app upgrade promotion (Try Admiral free callout, Skipper
and Admiral upgrade cards with feature lists, monthly/annual checkout
buttons) with a single subdued "See pricing" link to sencho.io/pricing.

The marketing site carries the upgrade story now; the dashboard's job
is to show the operator their current plan, accept a license key when
they have one, and point them to one place if they want to learn more.

Behavior by tier:
- Community: Plan info, Activate input, "See pricing" link.
- Trial (paid): Plan info, Activate input, no pricing link (the user
  already received a key by email).
- Active paid: Plan info with customer/product/key, Manage subscription
  and Deactivate. Unchanged from before.
- Expired paid: Plan info, Activate input, "See pricing" link as a
  recovery path.

Drops the SKIPPER / ADMIRAL_MONTHLY / ADMIRAL_ANNUAL Lemon Squeezy
checkout URL constants and the inline UpgradeCard component. -129 LOC
net.
2026-05-03 01:31:00 -04:00
Anso 1f8ce773ff feat(ui): hide paid features from community-tier dashboard (#891)
* feat(ui): hide paid features from community-tier dashboard

Community installs render only the features they can use. Tier-locked
sections, lock badges, upsell cards, and "Upgrade" buttons no longer
appear anywhere except the License page in Settings, which is the
single discoverable upgrade path.

Concretely:

- PaidGate and AdmiralGate now render null for non-qualifying tiers
  instead of upsell cards.
- SectionGate (settings) hides tier-locked sections entirely.
- Settings sidebar and command palette filter out items the operator
  cannot reach.
- Configuration Status widget on the dashboard drops the Automation
  section for community and hides any locked rows in remaining
  sections.
- Fleet > Status node cards drop locked summary rows.
- Stack action menu, sidebar bulk bar, file upload / download, scan
  comparison, network topology toggle, node label picker all hide
  for community instead of showing disabled affordances or "Upgrade"
  literal text.
- Removes tierUpsell, TierLockChip, and useDismissalState (no longer
  referenced).

Backend tier guards remain authoritative; this changes UI discovery
only.

* test(e2e): assert upload control is absent in community tier

The community-clean-ui change removes the "Upgrade to unlock upload"
pill from the file explorer. Update the matching e2e assertion to
verify the upload control is not rendered, instead of waiting for a
pill that no longer exists.
2026-05-03 01:17:02 -04:00
Anso b843b89ca4 feat(frontend): add LazyBoundary for chunk-load failure recovery (#875)
* feat(frontend): add LazyBoundary for chunk-load failure recovery

When a lazy chunk fetch fails, the existing top-level ErrorBoundary shows
"Something went wrong" with a "Try again" CTA. "Try again" cannot succeed
against a chunk URL that no longer exists on the server (typical post-
deploy case where the user's tab was opened against an older bundle).
The right remedy is to reload the tab so the browser fetches the new
hashed chunks emitted by the current build.

Add LazyBoundary, a section-local error boundary that:

- Detects chunk-load errors via a substring union covering Chrome / Edge
  ("Failed to fetch dynamically imported module"), Safari ("Importing a
  module script failed"), Firefox ("disallowed MIME type" thrown when a
  deploy serves SPA index.html for a missing chunk URL), older Webpack
  ("Error loading dynamically imported module"), and Vite ("Loading
  chunk/CSS chunk N failed").
- For chunk errors, renders a glass-card matching the LockCard aesthetic
  with an AlertTriangle icon, a "This part of Sencho needs a reload"
  message, and a Reload CTA that calls window.location.reload().
- For non-chunk runtime errors, falls back to "Something went wrong" +
  the error message + a Try again CTA. Try again is safe on this path
  because the lazy import has already resolved before the render error
  fires.
- Logs to console.error in componentDidCatch so the underlying failure
  is still observable.
- Has role="alert" so screen readers announce the failure.

Wrap every existing Suspense site (1 in SettingsPage, 7 in EditorLayout
including the security-history overlay, 1 in ResourcesView) with
LazyBoundary. The top-level ErrorBoundary remains the catch-all for
errors that escape the section-local boundary.

Includes a unit test enumerating each browser's documented chunk-load
message so a regression in any one runtime is caught early.

* fix(frontend): move isChunkLoadError to its own file to satisfy react-refresh/only-export-components
2026-05-02 03:40:45 -04:00
Anso 6b74767388 fix(frontend): short-circuit CapabilityGate and extract shared LockCard (#873)
CapabilityGate previously rendered the gated children behind a blur
filter and overlay pill when the active node lacked the required
capability. Combined with the lazy-loaded views from the recent
splitting work, this meant the chunk for FleetView, AuditLogView,
HostConsole, etc. fetched on click even when the user could not use
the feature, defeating the click-time IP protection the lazy split
was meant to deliver. CapabilityGate was the only always-leaking gate
in the app.

Replace the blurred-children render with a clean glass lock card that
explains the version mismatch ("Fleet Management is not available on
this node. <node> is running v0.42.0. Upgrade the node to use this
feature."). Children are no longer rendered, so the lazy children
never mount and no chunk fetch happens. All 13 consumers (5 full-page
views, 7 settings sections, 1 inline panel in ResourcesView) get the
new behavior automatically; no consumer-side changes required.

Extract a shared LockCard primitive used by both CapabilityGate and
the existing TierLockedCard inside settings/SectionGate. The two were
95% identical (same glass-card chrome, same icon framing, same text
hierarchy) and would have drifted as the design evolved. The shared
primitive accepts an icon, title, and body, with an optional className
for layout overrides; the inner geometry is fixed so every lock state
in the app shares the same visual rhythm.

PaidGate and AdmiralGate still fall through to "blurred preview with
small pill" after a user clicks Dismiss on their full-page upsell
(24h localStorage window). That post-dismissal click-time leak is
intentionally out of scope here; closing it would require either
removing the dismissal flow or replacing the blurred-children render
with a static placeholder, both UX decisions deserving their own PR.
2026-05-02 02:33:53 -04:00
Anso fd05b5ef4b feat(frontend): code-split paid-tier settings sections (#870)
Every paid-tier React settings section (Users, Webhooks, Security,
Labels, ApiTokens, Registries, CloudBackup, NotificationRouting) was
statically imported into the bundle Community installs download.
SectionGate's runtime tier check hid the components from view but did
not gate the download, so paid feature JSX, copy, error messages, and
prop interfaces shipped to every Community user. Anyone could open
DevTools and read the source.

Convert each paid section to a lazy() declaration that imports the
component module on demand, and remove the corresponding re-exports
from settings/index.ts so rollup actually splits the chunk (without
this, the static export path through the barrel collapses the lazy
import back into the main bundle and emits an INEFFECTIVE_DYNAMIC_
IMPORT warning).

Suspense sits outside SectionGate intentionally: SectionGate short-
circuits to TierLockedCard synchronously for locked tiers, so the
lazy children never mount and no fallback flashes. The skeleton only
appears for the brief window between an unlocked section's chunk
request and its first render.

Build evidence: 8 new chunks total ~89 kB raw / ~27 kB gzip; main
bundle shrunk from 1,537 kB / 421 kB gzip to 1,468 kB / 407 kB gzip.
No INEFFECTIVE_DYNAMIC_IMPORT warnings remain. Dev-server runtime
test: AccountSection (free, eager) loaded as request 221 on app boot;
CloudBackupSection (paid, lazy) loaded as request 351 only after
clicking the sidebar entry.

Non-settings paid views (FleetView, AuditLogView, etc.) are still
static and remain a follow-up.
2026-05-02 01:50:21 -04:00
Anso f62716f557 refactor(design): align typography, colors, and card surfaces to DESIGN.md (#859)
* refactor(design): align surface tokens to DESIGN.md §2

* refactor(design): canonicalize tracked-mono kickers and display rungs

* refactor(design): collapse to five-slot palette and align card surfaces
2026-05-01 03:01:00 -04:00
Anso a25acbec7c feat(editor): opt-in diff preview before save (#855)
* feat(editor): add useComposeDiffPreviewEnabled hook

* feat(editor): add ComposeDiffPreviewDialog component

* fix(editor): replace HTML entity with Unicode arrow in ComposeDiffPreviewDialog

* feat(editor): add diff preview toggle to Appearance settings

Added a new 'Diff preview before save' toggle in the Display section of the Appearance settings panel. Users can now enable or disable the side-by-side diff view before compose and env file edits are saved to disk.

* feat(editor): wire diff preview dialog into compose save flow

* fix(editor): snapshot diff content at open time and fix event name

- Fix useComposeDiffPreviewEnabled and useDeployFeedbackEnabled to use
  the canonical SENCHO_SETTINGS_CHANGED constant from @/lib/events
  instead of the hardcoded string literal (wrong value)
- Snapshot language, original, modified, and fileName into diffPreview
  state at click time to prevent tab-switching from corrupting dialog
  content mid-review
- Remove React.MouseEvent from diffPreview state; pass a no-op stub to
  deployStack in the confirm path (preventDefault/stopPropagation are
  no-ops on an already-settled event anyway)
- Add diff-modal screenshot and document the feature in editor.mdx and
  settings.mdx

* docs(editor): add settings-toggle screenshot for diff preview feature
2026-04-30 22:01:17 -04:00
Anso eead195529 feat(settings): dress the page to match the audit (#849)
* feat(settings): dress the page to match the audit (cyan rail, italic serif, two-column rows)

Brings the full-page Settings route into the Sencho voice. The page now
opens with a full-width PageMasthead (cyan rail, mono crumb, italic
serif title, contextual stat strip) above a sidebar and main-content
panel, each as a rounded-xl card inset on the dark background.

Sidebar drops the duplicate "Settings" header and the candy tier badges.
Group headers carry mono labels with visible/total counts; gated rows
get a neutral uppercase lock chip and dim. Active rows keep the cyan
2px rail.

Five new primitives (SettingsSection, SettingsField, SettingsCallout,
SettingsActions / SettingsPrimaryButton, TierLockChip) replace the
stacked label-input-help shadcn defaults and the per-section ad-hoc
chrome. AccountSection, AppearanceSection, LicenseSection, SystemSection,
NotificationsSection, DeveloperSection, AppStoreSection, AboutSection,
and SupportSection are migrated to the new layout. The list-driven
sections (Webhooks, Routing, Users, Labels, Security, CloudBackup,
ApiTokens, Registries, NodeManager, SSO) keep their list cards but get
the new chrome and primary CTAs.

Each section can publish contextual stats to the masthead via a small
context channel: 2FA state on Account, plan/trial/renews on License,
edited count on System, channel counts on Notifications, etc.

* refactor(settings): drop react-router-dom and align with DESIGN.md

The Settings page was the only surface using react-router-dom for sub-section
navigation. Every other primary view (Home, Fleet, Resources, App Store,
Schedules, etc.) drives view switching through a single activeView useState in
EditorLayout. This change removes the dependency end-to-end:

- App.tsx drops BrowserRouter
- EditorLayout adds 'settings' to the activeView union; SettingsPage renders
  inside the same flex-1 overflow-y-auto p-6 wrapper as siblings
- UserProfileDropdown receives an onOpenSettings callback instead of
  useNavigate. SettingsPage owns currentSection via props lifted to
  EditorLayout, so cross-component navigation (openLabelManager,
  onManageNodes, ConfigurationStatus rows) can route to a sub-section
- SettingsSidebar items become buttons (no more NavLink); SectionGate's
  redirect-on-invisible falls back through SettingsPage's safeSection memo
- e2e/nodes.spec.ts updates the Nodes selector from link to button role
- react-router-dom removed from package.json + package-lock.json

The visual treatment is brought into alignment with frontend/DESIGN.md,
which was rewritten this week to be the normative extract of the audit:

- PageMasthead: title text-3xl → text-[22px] Section rung italic; kicker
  11px → 10px Label rung; stat label tracking 0.22em → 0.18em; stat value
  font-medium for mono Stat-rung family discipline
- SettingsField helper: mono → sans Body rung 14/22; success tone now uses
  --success green (was incorrectly mapped to brand cyan)
- SettingsCallout: title tracking 0.18em; subtitle Body rung 14px; success
  tone now genuinely uses --success green; new brand tone for promotional
  callouts (Trial CTA, Admiral upgrade) that should read cyan
- SettingsActions: SettingsPrimaryButton renders mono uppercase tracked,
  size sm by default. DESIGN §9.10 requires "small mono uppercase, cyan-
  filled" for every Settings primary CTA
- TierLockChip: 9px → 10px Label rung floor
- SettingsSidebar: group header tracking 0.18em; ⌘K kbd 9px → 10px;
  aside gains text-card-foreground transition-colors per §10 canonical
  card class
- SettingsPage main panel: text-card-foreground transition-colors added;
  uses h-full overflow-auto p-6 to mirror FleetView's wrapper rhythm
- Field rows, section headers, action rows now consume var(--density-*)
  tokens with literal fallbacks so Settings respects the comfortable/
  compact toggle

* fix(e2e): update mfa openAccountSettings to match settings redesign

Settings now opens to the Account section by default when accessed from
the profile dropdown, and the Account section no longer renders an h2
heading element. Update the openAccountSettings helper to open the
correct section and assert on the Password h3 heading that SettingsSection
renders instead.

* test(e2e): fix MFA enrolment assertion after settings redesign

The 2FA enrolment badge was replaced with a kicker/field pattern.
Assert on the 'enrolled' text that the new design renders instead of
the removed Enabled badge.

* test(e2e): fix low-backup-codes warning assertions after settings redesign

Update two assertions in the 'low backup codes warning' test that
referenced UI text removed in the settings redesign:
- '1 backup code remaining' -> '1 remaining' (SettingsField body text)
- 'Regenerate now' button -> callout subtitle text, which uniquely
  identifies the zero-codes error card without hitting strict-mode
  from two identically-labelled Regenerate buttons on the page

* test(e2e): navigate to root before re-opening settings for mock refresh

The settings redesign uses a nested full-page route. Navigating to the
same URL a second time does not remount the component, so AccountSection
retains cached MFA state and the 0-codes branch never fetches. A
page.goto('/') ensures full unmount before the second openAccountSettings
call, so the refreshed mock is actually hit.

* test(e2e): scroll zero-codes callout into view before asserting visibility

The callout sits below the Disable 2FA section in the MFA settings page
and is scrolled out of the clipped content area on initial render.
scrollIntoViewIfNeeded() brings it into the visible viewport before the
toBeVisible assertion.

* test(e2e): scroll Radix ScrollArea viewport for zero-codes callout assertion

The settings page wraps content in a Radix ScrollArea whose Root has
overflow:hidden, so the browser's native scrollIntoView cannot scroll
the inner viewport. Wait for the callout to attach (confirms mock data
loaded), then programmatically set scrollTop on the Radix viewport
element before asserting visibility.

* test(e2e): use toBeAttached for zero-codes callout to avoid Radix clip issue

The callout renders below the Disable 2FA section, outside the visible
clip area of the Radix ScrollArea Root (overflow:hidden) on a standard
viewport. Playwright's visibility check uses the clip intersection, so
toBeVisible() fails even after programmatic scroll. toBeAttached()
confirms the component rendered the warning card for backupCodesRemaining:0
without depending on the element's scroll position.
2026-04-30 19:37:38 -04:00
Anso 9a1c043189 refactor(settings): replace modal with nested full-page route (#848)
* refactor(settings): replace modal with nested full-page route

Settings sections are now URL-addressable at /settings/:sectionId, rendered
nested inside EditorLayout alongside the stack sidebar. Browser back/forward
navigates between sections. Deep links (e.g. /settings/cloud-backup) load
the section directly on hard reload.

- Add react-router-dom v7; BrowserRouter wraps the full app tree
- New SettingsPage (scroll memory, Cmd+K palette), SettingsSidebar (NavLink
  active styling, back-arrow), SectionGate (visibility + tier lock card)
- Rename SectionId 'appstore' to 'app-store' so slug === SectionId
- Decouple SystemSection, DeveloperSection, AppStoreSection from modal-
  passed props; each fetches its own data on mount
- Replace onLabelsChanged prop chain with SENCHO_LABELS_CHANGED window event
- Drop onOpenSettings prop from UserProfileDropdown, HomeDashboard,
  ConfigurationStatus; each calls useNavigate directly
- Delete SettingsModal.tsx

* fix(settings): validate sectionId against registry before property write

Prevents prototype pollution (CodeQL js/remote-property-injection #243).
URL param sectionId is checked against SETTINGS_ITEMS before being used
as a property key on scrollPositionsRef.

* fix(settings): eliminate remote property injection via Map and registry-sourced key

Two-part fix for CodeQL js/remote-property-injection:

1. currentSection is now derived from SETTINGS_ITEMS.find().id (trusted
   registry data) instead of the raw sectionId URL param. The tainted
   string never flows into any property access.

2. scrollPositionsRef uses Map<SectionId, number> with .get()/.set()
   instead of a plain object. Map operations do not write to the prototype
   chain, removing the prototype pollution vector entirely.

* test(e2e): align settings selectors with full-page route

The settings refactor (4475afd) replaced the modal with a nested route.
The new sidebar renders sub-sections as NavLinks (role link, not button)
and adds a "Filter settings" button that collides with the loose
/settings/i regex used in mfa and nodes specs.

- Use exact 'Settings' match for the profile-dropdown menu row
- Switch the Nodes sub-section selector from button to link role
2026-04-30 12:57:02 -04:00
SaelixCode 3da0aa6036 chore: migrate repository URLs from AnsoCode/Sencho to studio-saelix/sencho
Updates all hardcoded GitHub repository references across 21 files:
- package.json: repository URL, bugs URL, homepage, description, author
- CONTRIBUTING.md: bug report template URL
- SECURITY.md: advisory URL, cosign cert-identity regexp
- .github/CODEOWNERS: @AnsoCode -> @studio-saelix/maintainers
- .github/workflows/ci.yml: repositories scope (Sencho -> sencho), docs-sync git URL
- .github/workflows/cla.yml: path-to-document URL
- .github/workflows/docker-publish.yml: cosign verify comment
- frontend/**/*.tsx: issues and changelog links (3 components)
- frontend/public/.well-known/security.txt: Contact and Policy URLs
- security/vex/sencho.openvex.json: @id field
- docs/openapi.yaml: license URL
- docs/docs.json: navbar and footer GitHub links (5 instances)
- docs/security.mdx: advisory and SECURITY.md links
- docs/reference/verifying-images.mdx: repo link + cosign regexp + legacy identity note
- docs/reference/contact.mdx: issues, LICENSE, advisory, policy, CoC links
- docs/reference/security-advisories.mdx: releases link
- docs/operations/verifying-images.mdx: cosign regexps and VEX download URL (6 instances)
- docs/operations/upgrade.mdx: releases links (2 instances)
- backend/src/utils/version-check.ts: GitHub Releases API endpoint

CHANGELOG.md intentionally excluded (release-please managed).
Legacy cosign identity note added for pre-migration image verification.
2026-04-29 09:24:20 -04:00
Anso 03f91cd5bb feat(cloud-backup): mirror fleet snapshots to S3-compatible storage (#782)
* feat(cloud-backup): mirror fleet snapshots to S3-compatible storage

Add an Admiral-tier Cloud Backup feature that replicates every fleet
snapshot to off-site storage, with two provider modes that share the
same `@aws-sdk/client-s3` code path:

- Sencho Cloud Backup: zero-config, 500 MB allowance backed by
  Cloudflare R2, provisioned via the sencho.io worker against the
  user's Lemon Squeezy license.
- Custom S3 (BYOB): any S3-compatible bucket (AWS, MinIO, Backblaze
  B2, Wasabi, R2 with own keys), with credentials encrypted via
  `CryptoService` before storage.

API-triggered snapshots upload fire-and-forget so the UI returns
immediately; scheduled snapshots block on the upload so the task's
success/failure reflects cloud durability. Object keys include the
instance_id segment to prevent collisions when the same Admiral
license is activated on multiple Sencho instances.

* fix(cloud-backup): drop ES2022-only Error cause arg breaking ES2020 build

The backend tsconfig pins lib to ES2020. The two-argument
`Error(message, { cause })` form requires ES2022, so tsc rejected it
with TS2554. Revert to single-argument throw to match the
convention used elsewhere in the backend services.
2026-04-26 15:42:21 -04:00
Anso dd9d33813b feat(deploy-logs): opt-in deploy progress modal with structured log rows (#779)
* feat(notifications): dispatch deploy_failure alert on stack action errors

* feat(terminal): add onReady and onMessage callback props

* feat(deploy-logs): add DeployLogContext with runWithLog API

* feat(deploy-logs): add DeployLogPanel bottom drawer with resize and minimize

* feat(deploy-logs): wire DeployLogContext to App and EditorLayout action runners

* test(deploy-logs): add E2E test for deploy log panel open, failure, and minimize

* docs(deploy-logs): add user-facing and internal architecture docs

* feat(deploy-logs): redesign as opt-in modal with structured log rows

Replace the full-width bottom drawer (DeployLogPanel) with a centered
modal that streams structured log output for deploy, stop, restart,
update, install, and Git apply operations. The modal is disabled by
default; users opt in from Settings -> Appearance.

Core changes:
- New DeployFeedbackContext with runWithLog() API: if opt-in is off,
  silently bypasses the UI so all call sites degrade to the existing
  toast behavior without code changes.
- composeLogParser.ts: pure parser that strips ANSI escapes and
  classifies compose output into stage badges (PULL, BUILD, CREATE,
  START, STOP, DOWN, WARN, ERR, LOG). 15 unit tests.
- StructuredLogRow.tsx: memoized row with timestamp, stage badge, and
  message. Error rows get a rose left rail; warn rows get a tinted bg.
- DeployFeedbackModal: Dialog-based, max-w-640px/max-h-70vh, elapsed
  timer, auto-close 4s on success (hover cancels), persistent on
  failure. Raw xterm output collapsible in footer.
- DeployFeedbackPill: minimized state anchored top-right, survives
  navigation, click restores modal.
- Wires App Store install (action: install), Git apply (action: deploy),
  and Git pull (action: update) in addition to the existing EditorLayout
  actions.
- Fixes Terminal.tsx WS URL in generic mode (was connecting to root path
  not proxied by Vite; now uses /ws).
- Settings: adds "Show deploy progress modal" checkbox to Appearance.
- Docs: renames deploy-logs.mdx to deploy-progress.mdx; updates
  internal architecture doc.

* fix(deploy-logs): connect Terminal in generic mode and move pill to bottom-center

Terminal was passed stackName which routes it to the stack logs WS
(container stdout). In that mode onReady is never called, so the
deployStarted gate never resolves and the compose command never runs.
Remove stackName so Terminal uses generic WS mode, which calls onReady
on open and streams compose output.

Also reposition the minimized pill from top-right to bottom-center
(fixed bottom-6 left-1/2 -translate-x-1/2) per UX feedback.

* docs(deploy-logs): update pill position to bottom center

* test(deploy-logs): rewrite E2E spec for deploy feedback modal

The old spec targeted the removed bottom-drawer DeployLogPanel and used
the wrong field name when calling POST /api/stacks (sent 'name' but the
endpoint reads 'stackName'), causing every test to fail with a 400 before
any UI assertions ran.

Fixes:
- POST /api/stacks body now uses 'stackName' matching the API contract
- All locators updated to target the new DeployFeedbackModal and
  DeployFeedbackPill components (data-testid attributes added)
- Added enableDeployFeedback helper to opt-in via localStorage before
  each test that expects the modal (feature is off by default)
- Added opt-in OFF test to confirm the modal is suppressed when disabled
- Minimize/expand test now asserts the pill appears and contains the
  stack name before clicking to restore the modal

* test(deploy-logs): fix compose file write endpoint in E2E helper

createStackViaApi was calling PUT /api/stacks/:name/files/docker-compose.yml
which does not exist. The correct endpoint is PUT /api/stacks/:name with
{ content } in the body.

* test(deploy-logs): use addInitScript to persist opt-in across reloads

The opt-in flag was set via page.evaluate before setupDeployStack, which
calls page.reload() and loginAs (a second navigation). Although localStorage
should persist across same-origin reloads, the React tree was reading
'false' on remount in CI. Switching to addInitScript guarantees the
localStorage value is set before any page script on every navigation, so
useDeployFeedbackEnabled's useState initializer always sees the right
value when React mounts.

* test(deploy-logs): verify localStorage and re-dispatch event before deploy

Adds syncDeployFeedbackState() called right before each deploy click in
the ON tests. It both verifies localStorage is set (failing the test
loudly with a clear message if not) and re-dispatches the
SENCHO_SETTINGS_CHANGED event to defeat any stale React state after
navigation. If the modal still does not appear with the assertion green,
the issue is downstream of localStorage and we have a clear signal.

* test(deploy-logs): wait for React re-render after dispatching opt-in event

After syncDeployFeedbackState dispatches SENCHO_SETTINGS_CHANGED, React
schedules the state update but does not flush it synchronously. The
click that follows can fire against the stale closure where isEnabled is
still false, so runWithLog takes its early-return path and the modal
never opens. A 200ms wait is enough to let React commit the new state
before the next interaction.

* test(deploy-logs): wait for stack file fetch before clicking deploy

deployStack() in EditorLayout returns early at 'if (!selectedFile)'
without calling runWithLog. selectedFile is set inside loadFile() after
GET /api/stacks/:name resolves. The previous setup clicked the stack in
the sidebar and immediately asked the test to click Deploy, racing the
fetch. CI backend logs confirmed no deploy POST ever fired for the ON
tests, while the OFF test passed only because it asserts non-existence.

Now setup awaits both the stack click and the file response together,
then verifies the action bar's deploy button is visible before returning.

* test(deploy-logs): wait for network idle and capture browser logs

Adds a networkidle wait plus a 500ms settle after the stack click so
React commits selectedFile and any follow-up env/container/backup
fetches drain before the deploy click. Also mirrors browser console
errors and pageerrors into the Playwright output so the next failure
ships with the React stack trace instead of just a 'modal not visible'
message.

* test(deploy-logs): temporary debug logging in runWithLog

Adds a console.log at the entry of runWithLog so we can see in CI logs
whether it is being called and what isEnabled value the closure has.
Also widens the test's console capture to include these debug lines.

This is diagnostic only and will be removed once the root cause of the
modal-not-opening-in-CI failure is identified.

* test(deploy-logs): debug log at deployStack entry to trace click path

Adds console.log at the first line of deployStack handler so we can
confirm in CI whether the click is reaching it at all and what
selectedFile/isStackBusy resolve to. Combined with the existing
runWithLog debug logs, this isolates whether the modal failure is in
deployStack guarding out, runWithLog early-returning, or something
else entirely.

* test(deploy-logs): drop filter, log every browser console msg

The previous filter only emitted error/warning plus the deploy-feedback
substring. The deploy-feedback debug logs never appeared, so we don't
yet know whether the log itself is firing. Remove the filter so the
full console stream shows up in CI.

* test(deploy-logs): app-level console log to verify capture pipeline

If even an unconditional log at App component render time does not
appear in CI browser logs, then the console capture listener is broken
or the dispatched logs are being filtered upstream of Playwright. This
isolates whether the issue is in the production code or the test
harness.

* test(deploy-logs): use testid locator for stack action button

Replaces the regex-based getByRole locator (/Deploy|Start/i) with
getByTestId('stack-deploy-button'). The regex matched something other
than the actual deploy button: backend logs proved no deploy POST ever
fired, and instrumentation confirmed neither deployStack nor runWithLog
ran on click despite the test claiming success.

Adds data-testid='stack-deploy-button' to both the Restart and Start
button branches in EditorLayout's action bar so the same locator works
whether the stack is running or not.

Also drops the temporary debug console.log entries in deployStack,
runWithLog, and App, and restores the test's console listener filter
to only emit error and warning messages.

* test(deploy-logs): park cursor in corner so auto-close countdown fires

After clicking the deploy button, the cursor lands inside the centered
modal. The modal pauses its 4s auto-close countdown on hover, so the
HAPPY test was waiting for a close that never happened. page.mouse.move
to (0,0) parks the cursor outside the modal before the success banner
appears, letting the countdown complete.

* test(deploy-logs): drop redundant loginAs after page.reload

page.reload preserves auth cookies, so the page lands back on the
dashboard without needing a fresh login. The loginAs call after reload
was racing on isLoginPage(): a transient login-page state during page
load made loginAs commit to filling #username, then the dashboard
committed and #username never came back. Playwright's auto-wait then
hung the fill until the test's 120s timeout, which also dragged later
stacks.spec tests down with collateral timeouts.

waitForStacksLoaded is enough to confirm we're on the dashboard with
the sidebar populated before clicking the new stack.

* test(e2e): make loginAs race-safe when login page is a false positive

isLoginPage() reports the page as a login screen if the Login button
locator reports visible at the moment of the check. Under CI load (more
real container deploys from the deploy-log-panel suite), the auth
context can render the login form for one paint, then redirect to the
dashboard. The original code committed to filling #username and hung
until the test timeout when the field was no longer there.

Now the login branch waits up to 2s for #username to actually appear
before filling. If it never appears, we fall through to the dashboard
check instead of hanging.
2026-04-26 00:43:54 -04:00
Anso e0034132b4 feat(notifications): match routing rules by labels and categories (#776)
- Add label_ids and categories columns to notification_routes via idempotent migration
- Matcher logic always evaluates routes (AND semantics across all non-empty matchers)
- getStackLabelIds skips DB call when no enabled route uses label filtering
- Extract ALL_NOTIFICATION_CATEGORIES array from NotificationService as single source of truth
- Derive VALID_CATEGORIES set from the array in the route handler
- Extract validateLabelIds and validateCategories helpers to remove POST/PUT duplication
- Extract tryAddColumn as a private DatabaseService class method (removes 5 local re-declarations)
- Extract CATEGORY_LABELS to frontend/src/lib/notificationCategories.ts (shared by NotificationPanel and NotificationRoutingSection)
- Frontend form adds label and category multiselects with AND-filter hint
- Route cards show label and category badges; empty-matcher routes show 'Matches all alerts'
- Add tests for category-only, label-only, and combined AND-semantics routing
2026-04-25 14:58:50 -04:00
Anso fcbdd59ec2 fix(notifications): scope routing rules to nodes via node_id column (#775)
Adds a nullable node_id column to notification_routes (null = any
node, integer = fire only when the alert originates from that specific
node). This fixes a multi-node fleet defect where a route scoped to
"my-app" would fire on every node that hosts a stack with that name.

Backend changes:
- DatabaseService: idempotent migration adds node_id INTEGER NULL and
  a composite index on (node_id, enabled, priority); the two statements
  are in separate try-catch blocks so the index is always created even
  when the column was added in an earlier run
- NotificationService: route matcher now pre-filters by node_id before
  checking stack_patterns (== null matches any node)
- notifications route: POST/PUT accept optional node_id, validated to
  be null or the local node's ID; NodeRegistry guards against
  cross-node misroutes

Frontend changes:
- NotificationRoutingSection: node scope Select field uses useNodes()
  from NodeContext (no extra API call) to populate the local node option
- Route cards show a node badge when node_id is set

Tests: 3 new tests covering node-match, node-mismatch, and null-scope;
all 75 files (1413 tests) passing.
2026-04-25 13:56:48 -04:00
Anso 4c35226719 fix(frontend): make copy buttons work over plain HTTP (#757)
The Clipboard API requires a secure context, so navigator.clipboard is
undefined when Sencho is accessed over HTTP on a LAN IP. Most copy
buttons therefore failed silently and a few even fired success toasts
without writing anything to the clipboard.

Extract a shared copyToClipboard helper that prefers the modern API in
secure contexts and falls back to a hidden-textarea execCommand path
otherwise, then route every existing call site through it.
2026-04-24 22:26:12 -04:00