Commit Graph

483 Commits

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

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

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

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

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

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

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

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

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

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

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

Two reinforcing changes:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Three follow-ups from independent review:

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

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

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

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

Copy is deliberately one line and states the requirement once:

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

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

Resolves M-3 from the stack-management audit.

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

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

  Error: useLicense must be used within a LicenseProvider

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  ! N nodes unreachable    > (expand)

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

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

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

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

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

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

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

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

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

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

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

Backend hardening covering three editor-served routes:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Three findings from the independent review:

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

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

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

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

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

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

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

* refactor(sidebar): apply Ops Pulse audit fixes

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

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

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

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

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

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

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

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

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

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

* fix(ci): restore backend and frontend checks

* fix(e2e): remove create button timing race

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

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

Independent audit on the previous commit surfaced two issues.

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

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

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

This fixes the Node 26 frontend CI failures where tests accessed localStorage during setup.
2026-05-23 06:05:54 -04:00
dependabot[bot] f6a2c7baae chore(deps): bump the all-npm-frontend group in /frontend with 4 updates (#1172)
Bumps the all-npm-frontend group in /frontend with 4 updates: [date-fns](https://github.com/date-fns/date-fns), [geist](https://github.com/vercel/geist-font/tree/HEAD/packages/next), [motion](https://github.com/motiondivision/motion) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `date-fns` from 4.2.1 to 4.3.0
- [Release notes](https://github.com/date-fns/date-fns/releases)
- [Commits](https://github.com/date-fns/date-fns/compare/v4.2.1...v4.3.0)

Updates `geist` from 1.7.0 to 1.7.1
- [Release notes](https://github.com/vercel/geist-font/releases)
- [Changelog](https://github.com/vercel/geist-font/blob/main/packages/next/CHANGELOG.md)
- [Commits](https://github.com/vercel/geist-font/commits/v1.7.1/packages/next)

Updates `motion` from 12.39.0 to 12.40.0
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.39.0...v12.40.0)

Updates `vite` from 8.0.13 to 8.0.14
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.14/packages/vite)

---
updated-dependencies:
- dependency-name: date-fns
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: geist
  dependency-version: 1.7.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-frontend
- dependency-name: motion
  dependency-version: 12.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: vite
  dependency-version: 8.0.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-frontend
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 03:06:10 -04:00
Anso 9d8d8abcba fix(stacks): close dialog and toast on Empty create (F-2) (#1168)
* fix(stacks): close dialog and toast on Empty create (F-2)

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

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

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

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

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

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

Two issues from independent review.

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

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

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

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

The rewrite turns the test inside-out:

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

No product code change. The synchronous creatingEmptyRef guard already
landed in 61e79861.
2026-05-23 03:04:53 -04:00
Anso efcc06d50b ci: harden CI and supply-chain pipeline (#1169)
* ci: harden CI and supply-chain pipeline

* Add frontend Vitest step to ci.yml so the 241 existing frontend tests run on
  every PR (mirrors the backend build/test/lint/audit order).
* Pin Node 26 as a single source of truth: new .node-version, node-version-file
  on all setup-node calls, engines.node ">=26.0.0" in all three package.json
  files. Matches the Dockerfile's node:26-alpine.
* SHA-pin remaining mutable actions in the start-app composite
  (actions/setup-node v6, actions/cache v4.3.0).
* Pin Dockerfile supply-chain inputs: golang:1.26.3-alpine by sha256 digest in
  both builder stages; replace mutable-tag git clone with commit-SHA fetch for
  docker/cli (v29.4.1) and docker/compose (v5.1.3). LDFLAGS version strings
  and otel patch preserved unchanged.
* Ref-scope docker-publish concurrency so two different release tags cannot
  cancel each other; same-ref reruns still cancel as before.
* Harden CLA workflow: drop actions:write from permissions; tighten the
  issue_comment trigger to PRs only (github.event.issue.pull_request != null)
  matching the two documented CLA phrases. No PR code is checked out.
* Drop trivy-version: latest from both Trivy scans so the SHA-pinned
  aquasecurity/trivy-action governs the bundled binary version. The
  HIGH/CRITICAL gate, severity filter, and trivy.yaml (OpenVEX) are unchanged.
* Restructure Dependabot: add applies-to: security-updates groups for npm
  (root/backend/frontend), docker, and github-actions; switch github-actions
  to directories so the local composite action is monitored alongside the
  top-level workflows.
* Add a daily scheduled SARIF security scan (security-scan.yml): two parallel
  jobs scanning saelix/sencho:latest and a fresh main HEAD build, uploading to
  GitHub code scanning. Least-privilege (contents: read, security-events:
  write). Visibility only; existing PR-blocking and release-blocking Trivy
  gates are not weakened.

Validation: backend tsc clean; frontend tsc clean; frontend npm test 27 files
/ 241 tests pass; npm audit --audit-level=high passes at root, backend, and
frontend; docker buildx build --check passes with no warnings (all pinned
digests resolve from the registry).

* ci(frontend): set explicit jsdom URL so localStorage initializes in CI

jsdom does not instantiate window.localStorage / sessionStorage when the
document has the opaque about:blank origin. Five frontend test files that
call localStorage.clear() in beforeEach started failing once the new
frontend Vitest step in this PR began running them on Linux CI runners.

Configuring environmentOptions.jsdom.url with a real same-origin URL is
the documented Vitest 4.x workaround and is a config-only change. All 27
test files (241 tests) pass locally with the fix applied.
2026-05-23 02:37:29 -04:00
Anso e4fe4cfced fix(mesh): address Codex audit findings on F-1 PR (#1158)
- docs(sencho-mesh): split subnet_overlap troubleshooting into env-set
  vs env-unset paths; rewrite the "Customising the mesh subnet" intro
  to describe the candidate list and the adopt-existing behavior.
- backend(MeshService): preserve idempotent 409 handling in the
  explicit-env path. On createNetwork 409 (TOCTOU race against another
  process), re-inspect and treat the race-winner as success when its
  subnet matches the operator's request; subnet_mismatch otherwise.
- frontend(MeshDataPlaneBanner): trim the card variant to a true
  one-line strip (headline only, truncate min-w-0). Full recovery
  hint stays on the Routing tab variant and in docs.
- tests(mesh): add five cases covering the previously untested
  branches — candidate-loop non-overlap bail, adopt-existing with
  unparseable subnet, explicit-env generic createNetwork failure,
  TOCTOU 409 race-winner match, TOCTOU 409 race-winner mismatch.

Architecture map (gitignored per Directive 11) updated locally with
the new useMeshDataPlane hook node and the mesh.dashboardBanner flow
so the local interactive viewer stays accurate.
2026-05-22 13:39:12 -04:00
Anso 1a03cf82af fix(mesh): auto-fallback through candidate subnets when default overlaps (#1156)
The default mesh subnet 172.30.0.0/24 is fully contained in linuxserver/*
default networks (sonarr_default 172.30.0.0/16, etc.), so libnetwork
rejects the IPAM allocation with "Pool overlaps with other one on this
address space" on a typical homelab Docker host. The single hard-coded
default left first-run operators with a silently broken mesh.

MeshService.setupMeshNetwork now resolves the subnet via three paths:

1. Operator-explicit (SENCHO_MESH_SUBNET set): use that subnet, strict.
   Pre-existing sencho_mesh with a different subnet still raises
   subnet_mismatch.
2. Adopt-existing (env unset, sencho_mesh already on the daemon): adopt
   the existing subnet. Docker is the source of truth across restarts.
3. Candidate iteration (env unset, no existing network): walk
   172.30.0.0/24, 172.31.0.0/24, 10.42.0.0/24, 10.43.0.0/24 in order.
   First subnet Docker accepts wins. If every candidate overlaps,
   record subnet_overlap with a message naming every attempt.

The dashboard's Fleet Heartbeat card now surfaces the down state via a
compact banner above the per-node rows, plus a "mesh down" counter
suffix on the right of the title. The existing Routing-tab banner is
extracted into a shared MeshDataPlaneBanner component with tab and
card variants. Dashboard polling is gated on Admiral tier so non-paid
users do not fire the Admiral-only /mesh/status endpoint.

Six new tests in mesh-setup-error-classification cover: iterates past
first overlap, all candidates overlap, adopts existing network,
inspectNetwork non-404 failure classified as attach_failed, env-matches-
existing skip-create, and operator-explicit strict (no fallback).

Fixes F-1 in the pre-1.0 audit. Closes the silent-failure mode that
left the mesh down on the most common homelab Docker layout.
2026-05-22 13:20:27 -04:00
Anso 519a59ed2e feat(fleet): open Fleet Actions tab to Community (admin-only) (#1153)
* feat(fleet): open Fleet Actions tab to Community (admin-only)

Removes the requirePaid guard from the five Fleet Actions endpoints
(fleet-stop, fleet-prune, match-preview, prune/estimate, bulk-assign)
and drops the matching isPaid parent gate on FleetActionsTab so Community
admins can run fleet-wide bulk operations. requireAdmin stays on every
endpoint; operator and viewer roles still 403 on apply.

Tests flipped from "403 PAID_REQUIRED on community" to positive
"reachable on community + admin" assertions. Docs (fleet-actions,
fleet-view, licensing, overview, stack-labels) rewritten to state the
admin-role requirement once and drop the prior Skipper framing.

* fix(fleet): apply audit findings from PR #1153 review

- stack-labels.mdx: fix the page intro that still framed fleet label
  actions as "Operators on a Skipper or Admiral license". The cards are
  now Community + admin, so the intro reads "Admins also get a pair of
  fleet-wide actions".
- Collapse redundant role-rule statements on the two affected pages.
  fleet-actions.mdx now states the admin gate once in the lead-in Note
  and again only in the troubleshooting accordion (the Prerequisites
  row was duplicative). stack-labels.mdx trims the "Limits and rules"
  bullet to the value-add half (label authoring is open to every role)
  and drops the Fleet Actions repetition.
- Strip now-no-op mockTier('paid') calls from non-tier tests across the
  three fleet test files, plus the test-wide default in the
  fleet-action-card-endpoints beforeEach. Those mocks were misleading
  after the routes stopped consulting tier; if a future change re-adds
  requirePaid the tests will fail loudly instead of silently passing.
2026-05-22 01:27:27 -04:00
Anso 60f893a81f feat(fleet): move bulk Remote OTA updates to Community tier (#1151)
Drops `requirePaid` from `POST /api/fleet/update-all` so Community admins
can dispatch bulk node updates. Per-node OTA was already Community-
reachable (admin-only); this completes the move so the full Remote OTA
surface ships at Community.

Frontend mirrors the backend: removes `canBulkUpdate` from
NodeUpdatesSheet so the "Update all (N)" affordance is purely data-
driven on `updatableRemoteCount > 0`.

Docs realigned to drop fence-spec and Skipper-only phrasing on the
Update all bulk action:
- features/licensing.mdx: Community line now lists Remote OTA (per-node
  and Update all); Skipper Fleet Actions parenthetical drops "bulk
  update all"
- features/remote-updates.mdx: Note rewritten to role-only requirement
- features/fleet-view.mdx: Update all (n) bullet drops the tier clause
- features/overview.mdx: Fleet View and Remote updates blurbs drop the
  Skipper/Admiral fences
- operations/upgrade.mdx: Note rephrased without naming tiers

Test coverage:
- fleet.test.ts: tier-gating spec flipped to assert Community access
- fleet-pilot-update.test.ts: bulk-OTA dispatch suite now spies tier
  to Community so it doubles as a regression guard
2026-05-21 23:54:45 -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 b740dd1078 feat(resources): protect Sencho's own image, network, volumes from deletion (#1149)
* feat(resources): protect Sencho's own image, network, volumes from deletion

Adds SelfIdentityService that reads HOSTNAME at startup and inspects the
running Sencho container via Dockerode to record its image ID, attached
networks, named volumes, and container ID. The classification API marks
these with isSencho:true, destructive delete routes return 423 Locked when
the target matches self, the orphan-containers API filters the Sencho
container out so it cannot be selected and purged from the Unmanaged tab,
and the managed-prune path adds an explicit self filter for
defense-in-depth on top of Docker's in-use semantics.

The Resources view renders a Sencho pill alongside the managed badge on
matching rows and disables the trash control with a hover tooltip.

When Sencho runs outside Docker (dev mode), inspect returns 404, the
service stays empty, and every isOwn* returns false so today's behaviour
is preserved.

* fix(resources): handle sha256-prefixed image IDs and custom hostnames

Addresses independent-review findings on PR #1149:

- Strip sha256: prefix in POST /api/system/images/delete before validating
  the ID, matching the inspect route's handling. Without this, /system/images
  responses round-trip through the UI as sha256:<hex> and got 400 Invalid
  image ID format before rejectIfSelf could run.
- Add /proc/self/cgroup fallback to SelfIdentityService so custom
  --hostname, Compose hostname:, or --uts=host setups still self-identify.
  HOSTNAME inspect runs first; on 404 the service parses the cgroup file
  for a 64-hex container ID (cgroupv1 docker, cgroupv2 docker, podman
  libpod formats all covered) and retries inspect with that ID.
- Restrict prefix matching in isOwnNetwork / matchesId to hex-shaped
  candidates (12 to 64 hex chars), so a non-Sencho network whose name
  happens to start with a hex prefix of Sencho's network ID is no longer
  flagged as self.
- Trim the resources.mdx Note to customer-visible behaviour without
  enumerating every tab.
- New tests: prefixed-image-ID 200 path, three cgroup file format
  parses (v1, v2, podman) plus the no-match and missing-file cases,
  HOSTNAME-404-then-cgroup-success fallback path, name-collision
  regression for the hex-only prefix rule, and an empty-cache
  no-regression check. Test hygiene: mockReset on the inspect stub and
  restoreAllMocks in afterEach so spies do not leak across tests.

* chore(security): VEX not_affected for CVE-2026-46680 (containerd in docker-compose)

Trivy now flags CVE-2026-46680 HIGH on
usr/local/lib/docker/cli-plugins/docker-compose, which statically embeds
github.com/containerd/containerd/v2 v2.2.3 (compose v5.1.3's resolved
module graph).

The CVE is a runtime-executor flaw: containerd's runc invocation can be
tricked into running a Kubernetes pod marked runAsNonRoot as root via
crafted user ID handling. The vulnerable code path is reached only by
containerd-shim executing a container with a populated OCI runtime spec
on the daemon side. docker-compose vendors the containerd Go module
purely as a client (gRPC stubs, API types, shared utilities); it never
executes containers and never enforces runAsNonRoot. Sencho's compose
usage (up / down / ps against user-authored files) cannot construct a
Kubernetes pod security context. The vulnerable path is unreachable.

Adds a not_affected entry to security/vex/sencho.openvex.json with
justification vulnerable_code_not_in_execute_path, bumps version 5 to 6,
and updates last_updated to 2026-05-22 per Directive 23.
2026-05-21 23:10:13 -04:00
Anso dc8a368785 fix(frontend): wire favicon to existing logo assets (#1146)
The previous `<link rel="icon">` pointed at `/sencho-logo.png`, which is
not present in `frontend/public/`, so every page load 404'd the favicon
and browser tabs fell back to the generic globe glyph.

Point the icon at the two PNGs that actually ship in `frontend/public/`
(`sencho-logo-dark.png` and `sencho-logo-light.png`) and let the browser
pick the right one via `prefers-color-scheme`. Add an `apple-touch-icon`
so iOS home-screen pinning gets a real asset instead of a screenshot.
2026-05-21 21:11:56 -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 535023b350 feat(files): open stack file explorer to every tier (#1144)
* feat(files): open stack file explorer to every tier

Drop the `requirePaid` guard from the seven stack-file write routes
(download, upload, write-content, delete, mkdir, rename, chmod) and
remove every matching `isPaid` check from the file-explorer frontend.
Stack edit permission (RBAC) continues to gate every write end-to-end.

The file explorer is the primary way a user touches a stack's on-disk
surface; gating it behind a paid tier conflicted with the principle
that Community covers single user-initiated actions while paid tiers
add automation and governance.

* docs(files): treat download as a read action, not a write

Download has no `requirePermission('stack:edit')` on the route and no
`canEdit` gate in the UI, so viewer accounts can download. Update the
top paragraph to list download under reads, and rewrite the
troubleshooting accordion to describe the actual gating (a file must be
selected) instead of asserting a role gate that does not exist.

* test(e2e): align stack-files spec with the new tier rule

The community-tier describe block asserted that the Upload control is
absent and the editor shows a `Read-only` chip; the admin-tier block
skipped on Community via `test.skip(tier !== 'paid')`. Both rules
reflected the previous gate, where writes required a paid tier.

Writes are now gated on the `stack:edit` role, not on the license tier.
Repurpose the community describe to assert that a Community admin
under a mocked community license still sees the Upload control and an
editable Save button. Drop the obsolete tier-skip in the admin describe
so upload, edit, delete, and download exercise on every tier. Update
stale comments to reference the role gate.
2026-05-21 19:35:31 -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 c491d309c1 feat(schedules): make every scheduled action available on Skipper (#1141)
Collapse the Admiral carveout that restricted restart, prune, auto_backup,
auto_stop, auto_down, and auto_start schedules to the Admiral variant.
Scheduled Operations stays at Skipper+ (paid). The action picker now lists
every supported operation for any paid admin, and the scheduler runner
executes every action on either variant.
2026-05-21 16:42:41 -04:00
Anso 2563e30424 fix(fleet): equalize action card heights, swap order, replace native autocomplete (#1138)
Three visual adjustments to the Fleet Actions tab:

1. Drop the xl:grid-cols-3 breakpoint. The cards carry live previews and
   per-node estimate wells that compress badly at 3-up on a 1440 monitor.
   2-up is the new max. Pair with auto-rows-fr so every row equalizes to
   the tallest content height, and let <FleetActionCard> stretch into the
   cell via flex flex-col h-full plus a flex-1 body. Footer pins to the
   bottom; the shorter card's body grows whitespace below its content.

2. Reorder the JSX to Prune, Bulk, Stop. Prune sits top-left with its
   live "~ N GB reclaimable" estimate populated on first render. Stop,
   which reads "awaiting target" until the operator types, drops to
   row 2 below the fold.

3. Replace the native <datalist> on the Stop card with a custom
   LabelAutocomplete: free-form text input + a Sencho-styled popover
   (border-glass-border, bg-popover, backdrop-blur) that filters the
   known-label set as the operator types. Click a suggestion via
   mousedown + preventDefault so the input stays focused and the
   selection registers before any blur-driven close. Outside-click and
   Escape close the popover. Operator can still run against a label
   that was not in the autocomplete set; the server-side match-preview
   resolves it.

Also: whitespace-nowrap on the primary button so "Stop fleet" /
"Prune fleet" do not wrap in tight viewports.
2026-05-21 12:51:54 -04:00
Anso 6f301e005a feat(fleet): redesign Fleet Action cards to the System Sheet recipe (#1137)
Lift <FleetActionCard> primitive from audit §20 / DESIGN §9.12. Migrate
the three v1 cards (stop-by-label, bulk-label-assign, prune-fleet-wide)
to consume it: cyan rail on every card, action class as a chip, blast
radius as a live readout in the toolbar, preview section replaces the
warning banner, footer carries reversibility plus freshness. Drop the
per-card tone rail, the icon prop, and cards/tone.ts.

Add /fleet/labels/match-preview and /fleet/prune/estimate for the live
blast readouts (chrome falls back to "preview unavailable" if either
404s). Add dryRun: true to the existing fleet-stop and fleet-prune
endpoints so the Dry run button rehearses the full code path (locks,
per-node fan-out, remote propagation) without firing the destructive
leaf call. Result flows into the existing ResultsList.

Extend <SheetSection> with optional meta. Add --action-transformative
semantic token.
2026-05-21 11:45:52 -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 cdb9cd414e feat(routing): redesign node cards to the System Sheet recipe (#1133)
Lift <RoutingNodeCard> primitive from audit §21 / DESIGN §9.13. Migrate
the fleet adapter to consume it: collapse five inline state pills into
a single nodeState prop, replace the k/v stat list with a mono meta
line, drop the per-row HEALTHY chip in favor of a state dot, and add a
real empty-state block with a primary CTA for every state. Compact
density body swap stays inside the component, no -compact fork.
2026-05-21 10:18:03 -04:00
dependabot[bot] fd00844a00 chore(deps): bump the all-npm-frontend group (#1130)
Bumps the all-npm-frontend group in /frontend with 11 updates:

| Package | From | To |
| --- | --- | --- |
| [date-fns](https://github.com/date-fns/date-fns) | `4.1.0` | `4.2.1` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.14.0` | `1.16.0` |
| [motion](https://github.com/motiondivision/motion) | `12.38.0` | `12.39.0` |
| [react-day-picker](https://github.com/gpbl/react-day-picker/tree/HEAD/packages/react-day-picker) | `10.0.0` | `10.0.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.7.0` | `25.9.1` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.2.14` | `19.2.15` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.0.1` | `6.0.2` |
| [eslint](https://github.com/eslint/eslint) | `10.3.0` | `10.4.0` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.59.3` | `8.59.4` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.0.12` | `8.0.13` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.6` | `4.1.7` |


Updates `date-fns` from 4.1.0 to 4.2.1
- [Release notes](https://github.com/date-fns/date-fns/releases)
- [Changelog](https://github.com/date-fns/date-fns/blob/main/CHANGELOG.md)
- [Commits](https://github.com/date-fns/date-fns/compare/v4.1.0...v4.2.1)

Updates `lucide-react` from 1.14.0 to 1.16.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.16.0/packages/lucide-react)

Updates `motion` from 12.38.0 to 12.39.0
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.38.0...v12.39.0)

Updates `react-day-picker` from 10.0.0 to 10.0.1
- [Release notes](https://github.com/gpbl/react-day-picker/releases)
- [Changelog](https://github.com/gpbl/react-day-picker/blob/main/packages/react-day-picker/CHANGELOG.md)
- [Commits](https://github.com/gpbl/react-day-picker/commits/v10.0.1/packages/react-day-picker)

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

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

Updates `@vitejs/plugin-react` from 6.0.1 to 6.0.2
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.0.2/packages/plugin-react)

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

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

Updates `vite` from 8.0.12 to 8.0.13
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.13/packages/vite)

Updates `vitest` from 4.1.6 to 4.1.7
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.7/packages/vitest)

---
updated-dependencies:
- dependency-name: date-fns
  dependency-version: 4.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: lucide-react
  dependency-version: 1.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: motion
  dependency-version: 12.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: react-day-picker
  dependency-version: 10.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-frontend
- dependency-name: "@types/node"
  dependency-version: 25.9.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: "@types/react"
  dependency-version: 19.2.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-frontend
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-frontend
- dependency-name: eslint
  dependency-version: 10.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: typescript-eslint
  dependency-version: 8.59.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-frontend
- dependency-name: vite
  dependency-version: 8.0.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-frontend
- dependency-name: vitest
  dependency-version: 4.1.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-frontend
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-21 01:02:36 -04:00
Anso 3ad6ea9c5d feat(pilot): make Docker Compose the canonical pilot enrollment payload (#1121)
* feat(pilot): make Docker Compose the canonical pilot enrollment payload

The Add Node dialog for a pilot-agent now returns a Compose snippet
instead of a single-line docker run command, and the enrollment dialog
walks the operator through a save-and-up flow. The compose project name
and container name align with what SelfUpdateService looks up at boot,
so a Compose-deployed pilot can be updated remotely through the Fleet
view without intervention on the remote host.

Docs (pilot-agent, remote-updates) were rewritten to match.

* test(e2e): align pilot enrollment spec with Compose payload

The spec was written against the docker-run payload; it now asserts the
Compose YAML the dialog renders.
2026-05-20 02:05:21 -04:00
Anso 0117556bea fix(fleet): rename Traffic tab label to Routing for consistency (#1119)
The Fleet view sub-tab that renders RoutingTab.tsx was labeled "Traffic"
while every adjacent identifier already used "Routing": the backend
route file (backend/src/routes/mesh.ts), the component path, the
localStorage key (sencho-routing-view-mode), the SegmentedControl aria
label ("Routing view mode"), and the engineering vocabulary across the
codebase. Operators looking for the "Routing tab" could not find it
because the visible label said something else.

Align the user-visible label with the rest of the implementation and
update the two doc references that named the tab by its old label
(docs/features/fleet-actions.mdx, .env.example).
2026-05-19 23:25:30 -04:00
Anso e05099f2a1 fix(fleet-sync): make control-identity-mismatch sticky and surface in UI (#1117)
Treat 409 CONTROL_IDENTITY_MISMATCH from a replica as a non-retriable
failure instead of looping the same 409 through the 5-minute retry
service forever and silently writing identical failure rows.

Backend
- DatabaseService: add `sticky_error_code`, `sticky_error_expected`,
  `sticky_error_got` columns to `fleet_sync_status` via an idempotent
  migration. New methods setFleetSyncSticky, getFleetSyncStickyCode,
  clearFleetSyncStickyForNode. recordFleetSyncSuccess clears the sticky
  flag on a clean push. getFailedSyncTargets SQL adds
  `AND sticky_error_code IS NULL` so the retry loop skips sticky rows.
- FleetSyncService.executePushToNode: short-circuits at the top when
  sticky is set (covers event-driven pushResourceAsync calls). On a 409
  with code CONTROL_IDENTITY_MISMATCH, records the failure once and
  pins sticky with the expected/got fingerprints carried in the 409 body.
- routes/nodes.ts: new POST /api/nodes/:id/fleet-sync/reset-anchor.
  Admin + paid + node:manage. Proxies POST /api/fleet/role/reanchor to
  the peer with `{override:true}` using the stored Bearer node_proxy
  token. On peer 200, clears every sticky row for the node so the next
  push re-anchors and resumes replication. Distinct 502 / 504 responses
  for peer-rejected / peer-unreachable so the UI can show a useful toast.

Frontend
- New lib/fleetSyncApi.ts + hooks/useFleetSyncStatus.ts. Polling hook
  (30s visibilityInterval) skips fetch when !isPaid.
- NodeManager.tsx: destructive banner per affected node listing both
  fingerprints, with `Reset anchor on peer` and `Remove node` buttons.
  Hidden for community-tier users via empty hook data.
- FleetConfiguration.tsx (Fleet -> Status): read-only `Policy sync`
  SummaryRow per remote node card. In sync / degraded / paused with
  a tooltip; no action buttons (the action lives in NodeManager).

Tests
- fleet-sync-service.test.ts: 4 new cases for sticky-set on first
  mismatch, short-circuit on subsequent pushes, null fingerprints,
  and non-mismatch failures not setting sticky.
- database-fleet-sync-sticky.test.ts (new): 6 cases pinning the DB
  contract incl. retry-loop SQL filter and migration idempotency.
- nodes-fleet-sync-reset-anchor.test.ts (new): 6 cases covering
  happy path, peer 401 -> 502, peer unreachable -> 504, local-node
  rejection, unknown node id, and community-tier 403.

Gate parity (Directive 30): the new POST .../reset-anchor enforces
requireAdmin + requirePaid + node:manage (matches the existing read at
GET /api/fleet/sync-status). UI banner + SummaryRow only render when the
hook returns data, which it only does for paid-tier authed users. No
existing tier-gate file moved; this is greenfield parity.

Auth audit: the peer's POST /api/fleet/role/reanchor route already uses
requireAdmin, which accepts the central's stored node_proxy Bearer
token because authMiddleware maps `scope === 'node_proxy'` to
`req.user = { username: 'node-proxy', role: 'admin', userId: 0 }`.
No widening required.

Backend tsc clean. Frontend tsc -b clean. 59 fleet-sync tests pass; full
backend suite green minus the pre-existing Windows-only file-lock flake
on filesystem-backup.test.ts that reproduces unchanged on main.
2026-05-19 19:49:16 -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 96d9bb9f76 fix(fleet-secrets): align bundle-row action aria-label with icon ("Send") (#1116)
The bundle-row action uses the lucide Send icon but the button's
aria-label was "Push", which surfaced inconsistently to screen-reader
users vs the visible/iconic intent. The same action is referred to
as "Send" in the feature docs, so standardise on Send everywhere.
2026-05-19 18:19:44 -04:00
Anso e3e5943b57 feat(fleet-secrets): add Import from stack action to bundle editor (#1115)
Adds an in-sheet "Import from stack" affordance to the Secret bundle
editor (edit mode only). Picks a node + stack + env file basename,
calls the existing /secrets/:id/import-from-stack endpoint, and
overlays the returned key/value pairs onto the editor: existing keys
have their values updated in place (preserving row order and any
duplicate-keyed rows for save-time validation), new keys append at
the bottom, empty placeholder rows are kept.

The endpoint and audit log row already shipped; only the UI trigger
was missing.
2026-05-19 18:19:28 -04:00