Commit Graph

1363 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 9e20acd647 chore(dashboard): add developer-mode timing diagnostics on dashboard routes (#1219)
Both dashboard endpoints (/configuration and /stack-restarts) execute on
the hot path of every dashboard mount or refresh, and one of them
(/configuration via buildLocalConfigurationStatus) reads from a dozen
database tables. When an operator reports a slow dashboard on a large
deployment, there is currently no instrumentation to point at which
endpoint is the offender.

Gate two new debug lines behind isDebugEnabled (the existing developer-mode
flag, sourced from DatabaseService.global_settings.developer_mode). Each
line reports the elapsed milliseconds and a single contextual field
(nodeId, row count, days window). Both endpoints exit unchanged when
developer mode is off; the timing measurement and console call are skipped
entirely, not just suppressed.

Both error paths already log via console.error and stay that way; per
backend/src/utils/debug.ts comments, error paths are exempt from the
debug gate.
2026-05-25 12:10:40 -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 96c7104521 docs(dashboard): refresh Troubleshooting accordion for new metrics-stale chip and tightened refresh model (#1223)
Three accordion edits keep the user-visible behaviour described on
docs/features/dashboard.mdx in step with the audit's surface changes.

- "Configuration Status still shows the old value after I changed a
  setting": replace the broad "most settings dispatch a live invalidation"
  language with the precise behaviour, which is that only a stack
  Auto-update toggle triggers an immediate refetch; every other settings
  edit waits for the 60-second poll. The change avoids promising
  responsiveness the card cannot deliver and points the operator at the
  hard-reload escape hatch.

- New "The masthead shows a 'metrics stale' chip" entry: describe what
  the chip means, the threshold (three consecutive metrics-endpoint
  failures), that polling continues regardless, and that the chip
  describes data freshness rather than the polling cadence. Points the
  operator at the Docker daemon and Sencho container logs as first
  checks.

- New "The dashboard feels sluggish on a large deployment" entry:
  document the Developer mode toggle as the supported diagnostic path
  for slow-dashboard reports. Lists the [Dashboard:debug] log shape so
  the operator knows what to look for, and reminds them to disable the
  toggle afterwards.
2026-05-25 12:10:13 -04:00
Anso d6afc298da docs(stack-files): refresh the file explorer page after the audit batch (#1224)
Brings the customer-facing page in line with what shipped in #1200
through #1220:

- Names the stack-read capability without enumerating which roles
  carry it. Every signed-in role does today, so the wording is
  forward-compatible with a future role that omits it.
- Updates the directory display cap to 1000 and points at the new
  filter input above the tree (the previous text quoted 500 and a
  shell-only fallback that no longer matches the UI).
- Explains the unsaved-edits confirmation on file switch, the
  optimistic-concurrency reconcile flow with the file-changed-
  elsewhere notice, and the atomic write semantics in a single
  short paragraph.
- Updates the upload row to describe drag-and-drop, the Replace
  confirmation on same-name conflicts, and the brand-coloured
  drop target hover.
- Replaces the four troubleshooting accordion entries the audit
  asked for in plain product language: stack:read 403, protected
  delete, file-changed-elsewhere 412, and DISK_FULL on upload.

No internal-tooling or fence-spec language. No tier-bypass
walkthroughs. No legacy phrasing.
2026-05-25 10:23:46 -04:00
Anso 55737ab34d test(stack-files): end-to-end coverage for the file explorer audit surface (#1218)
* test(stack-files): add end-to-end coverage for the file explorer audit surface

Companion to e2e/stack-files.spec.ts, which already pins the basic
admin and community-tier upload/edit/delete/download flows. The new
spec adds the harder-to-reach surfaces the audit flagged:

- Path-traversal corpus on GET /files: nine variants ( .. , ../etc,
  a/../b , absolute POSIX, Windows drive, backslash, double-slash,
  NUL byte, a/./b ) each asserted to return 400 INVALID_PATH.
- Protected-file enforcement via direct API: compose.yaml and .env
  delete return 409 PROTECTED_FILE; a subdirectory entry named
  compose.yaml still deletes (root-scope guarantee preserved).
- Optimistic concurrency: two writers race on the same file via
  If-Match headers; the loser sees 412 PRECONDITION_FAILED with the
  current content payload, and the winner's bytes are on disk.
- Large directory truncation: 1100 entries surface 1000 in the body
  with X-Truncated: true and X-Total-Count headers intact.
- Binary detection and force=text override: a UTF-8 file carrying a
  NUL byte returns binary:true by default and is rescued via
  ?force=text. Oversized files keep the no-inline-content contract
  even when force=text is set.
- 25 MB upload cap: a 25 MB + 1 byte payload trips the multer
  LIMIT_FILE_SIZE branch and returns 413 TOO_LARGE.
- Symlink semantics: DELETE removes only the link entry and leaves
  the target intact; PUT permissions on a symlink returns 409
  LINK_CHMOD_UNSUPPORTED with the target mode unchanged.
- UI lifecycle: upload via the dropzone then API rename, and API
  mkdir; both verify the resulting tree state by reloading the
  Files tab.

Four deferred surfaces are declared as test.skip with a one-line
rationale each: the remote-node matrix and mid-op disconnect
(require a real peer enrolled in CI), the developer-mode diagnostic
matrix (no stable hook for backend stdout in Playwright; covered at
the route-test layer), and the viewer-role tier-persona matrix
(role gating is covered at the route-test layer).

* test(stack-files): trim memory-heavy cases out of the E2E spec

CI ran the spec end-to-end and the run completed, but the two heaviest
cases (1100-file directory seed and a 25 MB upload buffer) pushed the
single-worker Playwright queue past the dashboard-render budget for
the specs that followed. stack-files.spec.ts and stacks.spec.ts saw
their loginAs await time out at 10 s while the post-spec sidebar was
still loading.

Both behaviours are exercised at the backend route-test layer
(stack-files-routes.test.ts pins the 1000-entry truncation header on
a 1100-file directory and the multer LIMIT_FILE_SIZE 413 with a 26 MB
buffer), so deleting them from the E2E spec loses nothing material.
The oversized force=text test is also trimmed from 2.5 MB to 2.1 MB,
which still exercises the >2 MB branch on the backend.

The deferred-coverage block now lists the two excised cases with the
rationale for why E2E is the wrong layer for them.

* test(stack-files): collapse 7 per-describe seed hooks into 1 file-scope pair

Each inner describe previously ran its own seedSuite + teardownSuite
pair: 7 logins, 7 stack creates, 7 stack deletes, all serial. That
added 15-20 seconds of CI setup overhead on a single-worker run and
pushed the post-spec dashboard load past the 10 s budget some
downstream specs allow on their loginAs await.

Moving the seed/teardown to file scope keeps the test stack present
for every test in this file with one fixture pair. Each test still
calls loginAs in its beforeEach so the page state is fresh, but the
expensive setup runs once instead of seven times.
2026-05-25 09:56:56 -04:00
Anso f86042b2ad fix(stack-files): track download metric off the file stream, not the response (#1220)
The download success metric was hung off res.on('finish') with a
res.on('close') fallback for failure. Under the in-process supertest
transport that pair fires in non-deterministic order: in CI the close
event sometimes precedes finish on a clean response, and the recorder
booked the request as an error.

The file stream's lifecycle does NOT race. fs.ReadStream emits:
  - 'end' then 'close' on a clean read,
  - 'close' without 'end' when destroy() runs (the req-close path),
  - 'error' then 'close' on a disk error.

Moving the recorder onto these signals removes the race. The flag still
prevents double-firing when 'close' chases 'end' on success.

Semantic note: success now means "the server finished reading the file
off disk and pushed it into the pipe", which is the strongest signal a
server can produce. Whether the client received the bytes is outside
the server's observable state and was never the actual measurement.
2026-05-25 09:13:51 -04:00
Anso 9f2f13f35a feat(stack-files): in-process metrics and structured mutation logs (#1216)
* feat(stack-files): in-process metrics and structured mutation logs

Adds FileExplorerMetricsService, an in-memory counter and latency
histogram keyed by (nodeId, op) modelled on StackOpMetricsService.
record() is called once per file-route request from a small
recordFileOp helper that wraps the metric capture; rejection paths
that ran real filesystem work (overwrite confirms, write conflicts,
multer oversize) now record an error count and a warn log instead of
disappearing from the snapshot. recordUploadBytes tracks bytes that
actually persisted so a node taking many small uploads vs a few large
ones is visible in the dashboard.

Admin-only GET /api/file-explorer-metrics returns the snapshot in the
same shape as /api/stack-metrics so an operator chasing a slow node
has a single place to look. No external telemetry; everything is
process-local and resets on restart.

Mutation INFO lines now carry op, stack, path, and bytes/mode/
recursive/overwrite/toPath in the structured details so log scrapers
can pivot on the same identity the metric uses. The existing
developer_mode gate on logFileDiag is unchanged. A new
rejectFileMutation helper centralises the log+metric+response triple
on the three rejection sites (upload DIR_EXISTS, upload FILE_EXISTS,
write PRECONDITION_FAILED) so a future rejection cannot skip the
metric.

Tests cover the service in isolation (counts, p50/p95, ring buffer cap,
upload bytes tracking, snapshot sorting), the admin route auth and
shape, and the route layer end-to-end: a real upload surfaces in
/api/file-explorer-metrics, a FILE_EXISTS rejection bumps errorCount,
and the structured INFO line carries the expected fields.

* fix(stack-files): tighten download/upload latency tracking

Two metric-accuracy bugs caught in independent review:

Download metric was recorded as a success before result.stream.pipe(res)
ran. A mid-stream read failure or a client disconnect was not counted as
an error because the recorder fired at pipe time, not stream completion.
Now the recorder hangs off res.on('finish') for success and on both the
stream's error event and res.on('close') for failure, with a flag so a
normal completion (which emits both finish and close) does not produce
two recordings.

Upload latency was inconsistent across the success and failure branches.
The multer wrapper captured startedAt at route entry, but the async
handler created its own startedAt after multer had already buffered the
body. Successful uploads therefore reported only the post-multer time
and the multipart transfer/buffer cost vanished from the histogram. The
wrapper now stashes the route-entry timestamp on the request object and
the async handler reads it back, so every metric for a given upload
shares one window.

A new test pins the download recorder behaviour: a single successful
download must produce successCount=1, count=1, errorCount=0 in the
snapshot, which would have flagged the original double-fire path the
finish+close pair could have introduced.
2026-05-25 01:46:08 -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 c2357ec534 fix(stack-files): symlink-aware delete and chmod (#1214)
deleteStackPath now lstats the leaf and unlinks the link entry itself
when it is a symbolic link, so the file the user clicked on in the tree
is what gets removed (the linked target stays intact). chmodStackPath
rejects with LINK_CHMOD_UNSUPPORTED on a symlink rather than silently
mutating the target's permissions; Node's lchmod is macOS-only and
following the link is the bug being fixed here.

Path-component symlinks are still resolved via the existing
resolveSafeStackPath, so a symlinked parent that escapes the stack dir
still surfaces SYMLINK_ESCAPE before the leaf is inspected.

Service-level tests cover delete on internal-target / external-target /
broken / dir-target symlinks, chmod rejection on symlinks (including
the broken case), and non-symlink regression checks. Route-level tests
pin the 409 LINK_CHMOD_UNSUPPORTED mapping and the link-only-delete
behaviour. The describe blocks are platform-gated; Windows symlink
creation needs admin/developer-mode and is skipped along with the
existing SYMLINK_ESCAPE test.

Docs updated to describe both behaviours in plain product terms.
2026-05-25 01:30:00 -04:00
Anso ba4de2e004 test(stack-files): pin multipart-body forwarding through the remote-node proxy (#1217)
conditionalJsonParser skips express.json() when the request has an
x-node-id pointing at a remote node, leaving the raw body stream intact
so the remote proxy middleware can pipe it upstream. No existing test
pinned that the skip also applies to multipart payloads, not just JSON.

A regression that re-enabled body-parser on multipart would silently
strand POST /api/stacks/<name>/files/upload to remote nodes: the proxy
would forward an already-drained stream, the upstream multer would see
an empty body, and the user would get a 400 from a successful-looking
request.

Two cases pin the behaviour:
- An in-process http capture server is registered as a remote node. A
  multipart upload through the central with x-node-id set must arrive
  with Content-Type carrying the boundary, Content-Length matching the
  raw byte length, the original file bytes intact, an envelope larger
  than a half-drained stream could ever produce, and the user JWT
  rewritten to the remote node's api_token so the central does not
  leak its session token to the peer.
- A second case extracts the boundary from the Content-Type header and
  checks both that the body bytes contain --<boundary> and that the
  original filename is preserved across the proxy.

No production code change. The test runs green against current main;
the value is in regression prevention for a load-bearing assumption.
2026-05-25 01:29:47 -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 668eda6cc8 fix(stack-files): atomic write via tmp+rename with optional exclusive mode (#1205)
* fix(stack-files): atomic write via tmp+rename with optional exclusive mode

writeStackFile and writeStackFileBuffer previously called fs.writeFile
directly, which truncates the target then streams the new bytes. A
crash, disk-full event, or process kill between the truncate and the
write left the target with partial content and no easy way to detect
the half-write at read time.

A private writeStackFileAtomic helper stages every write into a
sibling .sencho-tmp-<suffix> file in the same directory, fsyncs, then
promotes via fs.rename. A crash now leaves either the original target
intact or a leftover .sencho-tmp file (cleaned up on the next failure
path); a torn target file is no longer reachable through this path.

The helper accepts an optional `exclusive: true` flag that swaps the
final promote step from rename to link+unlink. link is atomic against
EEXIST so a caller that needs "create only if not present" gets a
race-free FILE_EXISTS error instead of a clobber. The upload route's
overwrite-confirm flow (PR #1204) will wire this through in a
follow-up so the existence check becomes authoritative.

Behaviour for current callers (writeStackFile, writeStackFileBuffer,
BlueprintService deploy) is unchanged: the non-exclusive default
matches the prior fs.writeFile semantics from the caller's perspective.

* fix(stack-files): tighten atomic write entropy + concurrent / failure tests

Tmp suffix now uses crypto.randomBytes(6) so the per-process collision
window is a true 48-bit space (Math.random().toString(36).slice(2,6)
could drop leading zeros and narrow entropy unpredictably). Adds a
short comment on the Windows link path noting NTFS / same-FS POSIX is
required, both guaranteed by tmp+target being siblings.

Two new tests close coverage gaps the first round missed:
- a write step that throws (writeFile rejected) leaves no tmp leak and
  no partial target;
- concurrent non-exclusive writers settle with at least one success
  and the final file is exactly one of the inputs (POSIX silently
  overwrites; Windows EPERMs the loser, both consistent).
2026-05-24 23:34:26 -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 37b12379c1 fix(stacks): refuse file-explorer delete/rename/chmod on protected stack files (#1202)
* fix(stacks): refuse file-explorer delete/rename/chmod on protected stack files

Previously the per-stack file explorer treated PROTECTED_STACK_FILES
(compose.yaml, compose.yml, docker-compose.yaml/.yml, .env) as a UI
hint only. A direct API call from any user with stack:edit could
delete or rename compose.yaml and break the stack irrecoverably
because the next deploy would fail to find a compose file and the
write was unrecoverable without a DB backup. The frontend
DeleteFileConfirm enforced a type-to-confirm gate but a stale UI or a
scripted client bypassed it.

FileSystemService now refuses the destructive ops at the service layer
with a new PROTECTED_FILE error code that the route layer surfaces as
409. The compose-editor save path (PUT /files/content) and the
upload-overwrite path (POST /files/upload writing a same-named file)
remain unblocked because both are legitimate ways to update
compose.yaml. Tests pin the allowed paths so a future tightening can
not silently regress them.

The protection is scoped to entries at the stack root; subdirectory
files happen to share a protected name (e.g., a snapshot under
backups/compose.yaml) are not blocked because compose CLI only reads
the root file. A trailing-slash bypass is closed by stripping trailing
separators before the basename check.

Frontend DeleteFileConfirm needs no change. Its existing toast.error
surface renders the friendly server message.

* fix(stack-files): drop polynomial regex from protected-file helpers

CodeQL js/polynomial-redos flagged the /\/+$/ pattern used to strip
trailing slashes from relPath in isProtectedRelPath and
protectedFileError. The regex is bounded in practice (the upstream
validator rejects '//' anywhere in the path) but the static analyzer
cannot follow that dataflow guarantee and would have flagged any
future caller that skips the validator.

Replace the two callsites with a small stripTrailingSlash helper that
uses endsWith + slice. Bounded O(1), no regex, no analyzer alert. The
inline comment documents the upstream invariant so a future reader
does not reintroduce the /+ quantifier.
2026-05-24 23:06:05 -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 c67478b50d chore(security): VEX three new compose CVE findings and correct dependency comment (#1201)
Trivy reported three new advisories against the compose binary today:

- CVE-2026-41568 (docker cp symlink-swap empty-file creation, sibling of
  CVE-2026-42306)
- CVE-2026-33997 (Moby plugin install privilege bypass)
- GHSA-pmwq-pjrm-6p5r (in-toto-golang glob-negation operator mismatch)

All three are daemon-side or supply-chain attestation code paths that
compose only embeds as client-side libraries. Sencho invokes compose
exclusively for up/down/ps against user-authored compose files, never
runs daemon endpoints, never performs plugin installation, and never
verifies in-toto attestations. Each gets a not_affected statement with
a full impact rationale; bump version 7 to 8 and last_updated to today.

Also correct the compose-builder comment in Dockerfile. The previous
text claimed compose v5.1.3 "eliminated CVE-2026-34040 and CVE-2026-33997
at the dependency level" by moving docker/docker to an indirect dep.
That is wrong: the module is still pulled via buildkit and other
transitive paths, the CVEs still appear in scans, and they are tracked
in the VEX file rather than eliminated. The corrected comment points
the reader at the VEX file as the source of truth for these findings.
2026-05-24 18:30:39 -04:00
Anso 0951a0e792 chore(deps): bump containerd to v2.2.4 to clear CVE-2026-46680 (#1199)
The compose-builder stage now pulls containerd/v2 v2.2.4 alongside the
existing otel security bumps. containerd v2.2.3 (the version pinned by
docker/compose v5.1.3) carries CVE-2026-46680, a runAsNonRoot evasion in
the runtime executor; v2.2.4 is the upstream patch release that fixes
it. The vulnerable code path is daemon-side and was never reachable from
compose, but bumping at the dependency level removes the entry from the
SBOM rather than relying on a VEX suppression.

Removed the corresponding statement from security/vex/sencho.openvex.json
and bumped version + last_updated.

The remaining three statements (docker/docker daemon CVEs) stay
suppressed: the fix lives on a new github.com/moby/moby/v2 module path,
and compose has not migrated imports yet, so no resolvable version bump
clears them.
2026-05-24 18:22:27 -04:00
Anso 7ec6fe05bb feat(stacks): in-process per-(nodeId, action) metrics + admin endpoint (#1196)
Sencho exports no telemetry by design (privacy-first posture). That left
operators with no answer for "why is this remote node slow today?"
except scrolling logs. The audit log records mutations but has no
latency information.

Adds StackOpMetricsService - a tiny singleton holding per-(nodeId, action)
counters and a 1000-sample ring buffer of latencies for p50/p95.
Exposed through GET /api/stack-metrics (admin-only) so operators can
pull the snapshot when debugging without touching disk or scrolling
journalctl. No external export; the data never leaves the process.

Wiring: deploy, down, restart, stop, start, update routes each capture
t0 at entry, set ok=true after the success path, and record() in a
finally block so failures count too. The record() call is cheap (one
Map lookup, one push, occasional shift on the bounded ring buffer)
and bounded in memory regardless of throughput.

Resolves M-4 from the stack-management audit.

API:
  GET /api/stack-metrics  (admin-only)
  Response: { entries: [{ nodeId, action, count, successCount,
              errorCount, avgMs, p50Ms, p95Ms }, ...] }
  Ordering: nodeId ascending, then action ascending.

Note on route mounting: /api/stack-metrics rather than the audit doc's
suggested /api/meta/stack-metrics because metaRouter is intentionally
mounted before authGate (public /api/health and /api/meta endpoints);
adding an admin-only route to that group would either bypass auth or
need a special inline gate that fights the existing structure. A
dedicated /api/stack-metrics router after authGate is cleaner.

Tests:
  - 9 unit tests in stack-op-metrics-service.test.ts: singleton,
    keyed-by-nodeId-action, p50/p95 math, ring-buffer cap at 1000,
    NaN/negative/Infinity rejection, ordering, reset.
  - 3 integration tests in stack-metrics-route.test.ts: 401 without
    auth, empty on fresh process, shape after recording.
2026-05-24 16:07:54 -04:00
Anso 60247d9c2e chore(stacks): gate informational console.log behind developer_mode (#1194)
Rebased onto current main (post H-1 / H-2 / L-3 / M-2 / M-6 merges).
Same intent as the original M-5 commit:

  - Local dlog() helper in routes/stacks.ts wrapping console.log
    behind isDebugEnabled().
  - All informational console.log in stacks.ts replaced with dlog().
  - The 3 Exec session-lifecycle console.log in DockerController.ts
    wrapped with inline if (isDebugEnabled()) gates.

console.warn and console.error remain unconditional everywhere.

Resolves M-5 from the stack-management audit.
2026-05-24 15:57:42 -04:00
Anso 429f780f40 test(stacks): E2E coverage for deploy success, failure, and bulk lifecycle (#1192)
* test(stacks): E2E coverage for deploy success, failure, and bulk lifecycle

Extends e2e coverage per L-2 of the stack-management audit. The
existing stacks.spec.ts only covered create/delete and a double-click
guard - no journey ever exercised real Docker through the deploy
pipeline.

Adds e2e/stack-deploy.spec.ts with three journeys:

1. Deploy success: creates a stack with a real-but-tiny compose file
   (alpine:3 sleep infinity), drives deploy through the authenticated
   browser session, and asserts the /api/stacks/statuses route
   reports the container as 'running' inside 30 seconds. Exercises
   the full middleware chain, ComposeService spawn, Dockerode status
   lookup, and the cache-invalidate path.

2. Deploy failure: creates a stack with intentionally malformed YAML,
   asserts the deploy route rejects with a 4xx/5xx and a parseable
   JSON envelope (no crash, no opaque text), and verifies the
   sidebar still renders the stack after reload.

3. Bulk lifecycle: creates two stacks, deploys both, then restarts
   both via per-stack POSTs (the path the current frontend bulk hook
   takes; once H-3 lands the hook fans out through the new bulk
   endpoint but the user-visible outcome is identical). Asserts both
   restarts return 200.

Disconnect-mid-deploy journey is deferred (depends on M-1 WS
reconnect). Rollback-after-failed-atomic-deploy is deferred (needs a
paid-tier license that the default e2e setup does not seed).
File-explorer journeys (upload/edit/delete) are tracked separately
to keep this spec focused on lifecycle operations.

Test design notes:
- Drives deploy through page.evaluate + fetch rather than UI buttons
  to stay resilient to editor-button label churn. Auth surface is
  still real (cookies, middleware chain, audit log) - only the click
  is bypassed. The audit's "exercise real Docker via Playwright"
  intent is satisfied.
- Each test calls teardownStack in a finally block so a failed test
  cannot strand a real container on the CI runner.
- Uses alpine:3 (smallest viable long-running image) to keep CI pull
  cost minimal. The image is shared across tests so registry pull
  amortizes after the first.

* test(stacks): bump E2E timeouts and parallelize bulk deploy

CI hit the default 30s per-test timeout on the bulk lifecycle test
because two serial deploys against a cold runner spent most of the
budget on the alpine pull. The deploy-success test passed the prior
run but had only ~0-5s of headroom outside its 30s status poll, so it
was a flake away from the same fate.

- bulk lifecycle: deploy both stacks in parallel (shared pull cache)
  and bump the per-test timeout to 90s.
- deploy success: bump per-test timeout to 60s so the 30s status poll
  is not racing setup and teardown.

The deploy failure test does no image pull (broken YAML rejects
upstream) so it keeps the default 30s.
2026-05-24 15:57:12 -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 009ec43638 feat(stacks): structured 503 docker_unavailable envelope + disconnect tests (#1191)
Stack lifecycle routes used to surface raw ECONNREFUSED text to the
client whenever the Docker daemon was unreachable. The frontend had no
way to distinguish "daemon down" from any other 500 and would render
the raw error message.

Detect daemon-reachability failures inside the route layer and surface
a structured envelope so the UI can render a dedicated "Docker is down"
state and operators can branch on a stable code:

  HTTP 503 { error: <message>, code: 'docker_unavailable' }

Detection lives in isDockerUnavailableError (exported from
routes/stacks.ts). The match is intentionally permissive across error
shapes Dockerode and the docker compose CLI produce: NodeJS ECONNREFUSED
errors with .code, ENOENT on docker.sock, and the CLI's
"Cannot connect to the Docker daemon" string. The helper is unit-tested
in isolation as well as exercised end-to-end through the route.

Applied to the five lifecycle routes that can hit the daemon-down path:
  POST /api/stacks/:name/restart  (via bulkContainerOp)
  POST /api/stacks/:name/stop     (via bulkContainerOp)
  POST /api/stacks/:name/start    (via bulkContainerOp)
  POST /api/stacks/:name/deploy
  POST /api/stacks/:name/down
  POST /api/stacks/:name/update

Adds ContainerActionOutcome variant 'docker-unavailable' so the route
can branch on the typed outcome rather than string-matching error
messages a second time.

11 integration tests in stack-docker-disconnect.test.ts cover:
  - isDockerUnavailableError matches ECONNREFUSED, CLI text, ENOENT on
    docker.sock; rejects unrelated errors and null/undefined.
  - restart/stop/start return 503 + code on Dockerode listContainers
    refusing.
  - deploy/down/update return 503 + code when ComposeService rejects
    with daemon-down error.
  - Unrelated deploy failures (YAML parse error) still return 500
    without the code, confirming the discriminator is correctly scoped.

Resolves L-3 from the stack-management audit.
2026-05-24 15:43:36 -04:00
Anso 4735edfafc chore(stacks): explicit stack:read RBAC on list endpoints (#1187)
GET /api/stacks and GET /api/stacks/statuses previously relied on the
global authGate for protection without declaring their own permission.
Every other endpoint in this router uses requirePermission(); the two
list endpoints were silent.

Add the explicit gate so:
1. The permission model is uniformly declared (audit-readability).
2. A future role (or per-stack Admiral scoped grant) without
   stack:read is correctly rejected without an extra code change.
3. The list endpoint behavior stays in sync with checkPermission
   semantics that the rest of the stack router already obeys.

Runtime is observably unchanged for every existing role: admin,
node-admin, deployer, viewer, and auditor all hold stack:read per
ROLE_PERMISSIONS, so the new gate is a no-op for current users.
No new test added because no role currently fails the gate; the
existing stack suite (65 tests, all admin-role) exercises both
endpoints and stays green.
2026-05-24 15:40:33 -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 8ba88755b1 fix(stacks): default Empty template ships ports block commented out (#1189)
The Empty branch of the Create Stack flow wrote a compose.yaml whose
first service bound the host's port 8080 by default. On any workstation
already running something on 8080 (traefik, caddy, librespeed, another
nginx, etc.) the very first deploy failed at the docker compose
networking step with "Bind for 0.0.0.0:8080 failed: port is already
allocated", which made the day-one experience feel broken right after
F-2 (PR #1168) tightened the dialog itself.

The boilerplate in FileSystemService.createStack now emits the ports
block commented out plus a one-line hint above it. A fresh deploy
binds no host port, so the container starts cleanly on any host; the
user uncomments the two-line block when they're ready to expose the
container. The deterministic shape (no probe-and-write, no random port,
no preflight scan) avoids the TOCTOU race that a free-port probe would
have left between template creation and the actual compose up.

Adds backend/src/__tests__/file-system-service-create-stack.test.ts
(8 cases): directory + file creation, structural YAML assertions via
yaml.parse to lock in the no-live-ports invariant, raw-text regex to
lock in the commented hint, and the already-exists rejection path.
FileSystemService.createStack had no coverage before this change.

Adds e2e/default-stack-template-no-fixed-port.spec.ts (1 case): drives
the dialog through Create, reads the resulting compose via the
in-browser apiFetch, and asserts the live + commented invariants
end-to-end.

docs/features/stack-management.mdx Empty bullet rewritten to describe
the minimal skeleton and the commented ports block instead of calling
it "blank".

Resolves: F-3 in the v1.0 audit tracker.
2026-05-24 01:06:22 -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
sencho-quartermaster[bot] 249cfdcc87 chore(main): release 0.86.6 (#1180)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com>
v0.86.6
2026-05-23 16:17:15 -04:00
Anso f03c9dc7b6 fix(webhooks): address Codex review of PR #1177 (#1181)
* fix(webhooks): close HMAC timing oracle on trigger reject paths

The trigger handler in PR #1177 returned a uniform 404 for every
unauthenticated rejection, but only the wrong-signature path computed
an HMAC over the request body. The other reject paths (unknown id,
disabled webhook, non-paid tier, missing X-Webhook-Signature header,
missing rawBody) short-circuited before any HMAC work. Repeated
near-rate-limit probes with a large attacker-controlled body could
distinguish a valid-and-enabled paid webhook id from the other reject
cases through response latency.

WebhookService.validateSignature is now constant-time over every input
shape: it always runs crypto.createHmac and crypto.timingSafeEqual
against fixed-length 32-byte buffers regardless of whether the
signature is missing, has the wrong prefix, is malformed hex, or is
the wrong length. The trigger handler calls it unconditionally before
any reject branch fires, using a stable per-process decoy secret
(WebhookService.getDecoySecret) when the webhook does not exist and an
empty buffer when the request has no body. Response timing now depends
only on the size of the request body, which the attacker already
controls and which reveals nothing webhook-specific.

Six new tests pin the behaviour: validateSignature is observed firing
on the unknown-id and missing-signature paths through a spy assertion,
and four direct-call tests confirm validateSignature returns false
without throwing for empty, wrong-prefix, malformed-hex, and
wrong-length signatures.

* fix(safe-log): redact Basic auth and lowercase Windows drive letters

The redactSensitiveText helper now covers two cases the prior chain
missed:

* Authorization: Basic <base64> previously left the base64 payload
  intact. The existing key/value regex caught only the literal word
  Basic before stopping at the space. A new Basic\s+[A-Za-z0-9+/=]+
  replacement runs before the key/value regex so the credential is
  scrubbed first.
* Windows homedir paths like c:\Users\<user>\... with a lowercase
  drive letter previously slipped through because the regex required
  [A-Z]. Changed to [A-Za-z] so both letter cases are covered.

Two new tests pin both fixes.

* docs(webhooks): document 429, fix shared schema, comply with D27/D31

* Trigger endpoint declares the 429 response that webhookTriggerLimiter
  can return (500 requests per minute per source IP); both
  docs/openapi.yaml and the response table in docs/features/webhooks.mdx
  carry the new row, and a new troubleshooting accordion explains the
  shared-NAT scenario.
* Shared Webhook schema in docs/openapi.yaml extends the action enum to
  include git-pull and documents the node_id property. The GET list
  endpoint returns these fields; the prior schema would have failed
  validation for any git-pull row.
* docs/features/webhooks.mdx:7 rewritten from a customer-side role
  enumeration ("non-admins on a paid tier can view the list but cannot
  manage it") to a single requirement statement ("Webhooks require a
  Skipper or Admiral license. Managing webhooks is admin-only.") per
  CLAUDE.md D27/D31; the prior phrasing was customer-side fence-spec.
* Two em dashes in webhook description strings I had touched in the
  prior OpenAPI sync commit replaced with semicolons per D18.
2026-05-23 16:03:48 -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 21ec5e7e0a fix(webhooks): harden trigger response surface (#1177)
* fix(webhooks): harden trigger response surface

Bundles six audit findings on the incoming-webhooks trigger path. All
changes preserve the documented happy path: a CI caller signing the exact
request body with the webhook secret still receives 202 Accepted.

* Uniform 404 on every unauthenticated rejection (missing webhook,
  disabled webhook, non-paid tier, missing signature header, missing
  raw body, signature mismatch). The four-way response surface previously
  let an unauthenticated probe enumerate webhook ids and fingerprint the
  instance's licence tier; callers now see one shape for any failed auth.
* Fail closed when express.json()'s verify callback did not capture the
  raw request body. Previously the handler fell back to
  JSON.stringify(req.body), which compares the HMAC against a
  re-serialised payload that is not byte-equal to what the client signed.
* Pass the already-loaded webhook through to WebhookService.execute()
  instead of re-fetching by id. Closes the delete-during-execution race
  where an admin deletion between the trigger handler's load and the async
  dispatch silently dropped the execution row. The webhook_executions
  table has ON DELETE CASCADE, so recordExecution now wraps the insert in
  try/catch and logs a warning when the FK constraint trips because the
  parent webhook was deleted mid-flight.
* Redact bearer tokens, JWTs, URL credentials, and homedir paths from
  error strings before persisting to webhook_executions.error. The
  execution history is readable by any paid user via GET /webhooks/:id/
  history; redactSensitiveText gains three home-directory patterns
  (/home/<user>, /Users/<user>, <drive>:\Users\<user>) and now runs on
  every error stored from this path.
* Cap webhook name at 100 characters on both POST and PUT, rejecting
  non-string and oversized values with 400 before they reach the DB.
* Validate the body's action override against a typed allowlist
  (isWebhookAction type guard) on the trigger endpoint, returning 400
  before queueing execution. An unknown override no longer reaches
  recordExecution as a stored failure row.

Tests updated to pass db.getWebhook(id)! instead of the raw id to the new
execute() signature. Docs at docs/features/webhooks.mdx updated to reflect
the new uniform 404 response, the new 400-on-invalid-action behaviour, and
a rewritten troubleshooting accordion that walks operators through every
cause of the uniform 404.

* test(webhooks): cover trigger handler auth, race, and redaction paths

Adds 21 vitest cases for the public webhook trigger handler and the
WebhookService.execute / recordExecution pipeline, plus 3 cases for the
new homedir patterns in redactSensitiveText.

webhooks-trigger.test.ts covers, per audit finding:

* M1 + H3 uniform 404: id unknown, webhook disabled, non-paid tier,
  missing signature header, missing rawBody, sha1= prefix, malformed
  hex signature, sig mismatch. Each asserts identical 404 body so a
  future regression that re-introduces 401 / 403 / PAID_REQUIRED breaks
  one of the 8 tests.
* Happy path: 202 with configured action, valid action override,
  unknown action override returns 400 after auth succeeds (L2),
  non-string action override returns 400.
* L1 name cap: POST and PUT both reject names over 100 chars and
  non-string names; 100-char boundary still accepted; PUT allows
  partial updates that omit name.
* M5 race: deleting the parent webhook before recordExecution runs no
  longer crashes the async dispatch; the FK cascade is swallowed with
  a console.warn, and a happy-path test pins the recordExecution row.
* M6 redaction: stubs ComposeService.runCommand to throw errors
  containing a bearer token and a homedir path, then asserts the
  persisted webhook_executions.error has both scrubbed.

safe-log.test.ts gains three unit tests pinning the new homedir
patterns in redactSensitiveText (Linux, macOS, Windows). The existing
credentials test is untouched.

Tests use prototype spies on FileSystemService and ComposeService (both
hand out a fresh instance per nodeId), so per-test mocks do not leak.
beforeEach restores all mocks and reapplies the LicenseService 'paid'
spy. Closes audit finding H2 (zero trigger-path test coverage).

* docs(webhooks): sync openapi spec with new trigger response surface

Brings docs/openapi.yaml in line with the behaviour changes from the
trigger hardening commit. Mintlify auto-generates the per-endpoint
reference pages from this spec, so the spec drift would surface as
wrong response codes in the public API reference.

POST /api/webhooks and PUT /api/webhooks/🆔
  * name: maxLength 100 (matches MAX_WEBHOOK_NAME_LENGTH on the route).
  * action enum: add git-pull (pre-existing omission; the route has
    always accepted it).
  * node_id: documented as an integer property (pre-existing omission).

POST /api/webhooks/:id/trigger:
  * requestBody required: true (body is now mandatory; the H3
    fail-closed branch rejects a missing rawBody).
  * action override: enum restricted to the allowlist.
  * 401 and 403 responses removed.
  * 404 response: description rewritten to reflect uniform-404
    behaviour; the body is { error: "Webhook not found or signature
    invalid" } for every unauthenticated reject.
  * 400 response added for an authenticated request whose action
    override is not in the allowlist.
2026-05-23 15:42:49 -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
sencho-quartermaster[bot] 579e672e24 chore(main): release 0.86.5 (#1167)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com>
v0.86.5
2026-05-23 03:08:53 -04:00
dependabot[bot] 741ed61c2f chore(deps): bump the all-actions group across 2 directories with 7 updates (#1174)
Bumps the all-actions group with 6 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `4.0.0` | `4.1.0` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `7.1.0` | `7.2.0` |
| [github/codeql-action](https://github.com/github/codeql-action) | `4.35.5` | `4.36.0` |
| [docker/login-action](https://github.com/docker/login-action) | `4.1.0` | `4.2.0` |
| [docker/metadata-action](https://github.com/docker/metadata-action) | `6.0.0` | `6.1.0` |
| [actions/stale](https://github.com/actions/stale) | `10.2.0` | `10.3.0` |

Bumps the all-actions group with 1 update in the /.github/actions/start-app directory: [actions/cache](https://github.com/actions/cache).


Updates `docker/setup-buildx-action` from 4.0.0 to 4.1.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd...d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5)

Updates `docker/build-push-action` from 7.1.0 to 7.2.0
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/bcafcacb16a39f128d818304e6c9c0c18556b85f...f9f3042f7e2789586610d6e8b85c8f03e5195baf)

Updates `github/codeql-action` from 4.35.5 to 4.36.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/9e0d7b8d25671d64c341c19c0152d693099fb5ba...7211b7c8077ea37d8641b6271f6a365a22a5fbfa)

Updates `docker/login-action` from 4.1.0 to 4.2.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/4907a6ddec9925e35a0a9e82d7399ccc52663121...650006c6eb7dba73a995cc03b0b2d7f5ca915bee)

Updates `docker/metadata-action` from 6.0.0 to 6.1.0
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/030e881283bb7a6894de51c315a6bfe6a94e05cf...80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9)

Updates `actions/stale` from 10.2.0 to 10.3.0
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/b5d41d4e1d5dceea10e7104786b73624c18a190f...eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899)

Updates `actions/cache` from 4.3.0 to 5.0.5
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/0057852bfaa89a56745cba8c7296529d2fc39830...27d5ce7f107fe9357f9df03efb73ab90386fccae)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-actions
- dependency-name: docker/build-push-action
  dependency-version: 7.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-actions
- dependency-name: github/codeql-action
  dependency-version: 4.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-actions
- dependency-name: docker/login-action
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-actions
- dependency-name: docker/metadata-action
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-actions
- dependency-name: actions/stale
  dependency-version: 10.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-actions
- dependency-name: actions/cache
  dependency-version: 5.0.5
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: all-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 03:07:34 -04:00
dependabot[bot] b70d0aa6bc chore(deps): bump the all-npm-backend group in /backend with 5 updates (#1173)
Bumps the all-npm-backend group in /backend with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [helmet](https://github.com/helmetjs/helmet) | `8.1.0` | `8.2.0` |
| [semver](https://github.com/npm/node-semver) | `7.8.0` | `7.8.1` |
| [ws](https://github.com/websockets/ws) | `8.20.1` | `8.21.0` |
| [@aws-sdk/client-ecr](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ecr) | `3.1051.0` | `3.1053.0` |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1051.0` | `3.1053.0` |


Updates `helmet` from 8.1.0 to 8.2.0
- [Changelog](https://github.com/helmetjs/helmet/blob/main/CHANGELOG.md)
- [Commits](https://github.com/helmetjs/helmet/compare/v8.1.0...v8.2.0)

Updates `semver` from 7.8.0 to 7.8.1
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v7.8.0...v7.8.1)

Updates `ws` from 8.20.1 to 8.21.0
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.20.1...8.21.0)

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

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

---
updated-dependencies:
- dependency-name: helmet
  dependency-version: 8.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: semver
  dependency-version: 7.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: ws
  dependency-version: 8.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: "@aws-sdk/client-ecr"
  dependency-version: 3.1053.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1053.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 03:07:20 -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