Commit Graph

132 Commits

Author SHA1 Message Date
Anso 3b650523c1 Audit-hardening pass for secret and misconfiguration scanning (#977)
* fix(security): dedupe concurrent compose-stack scans

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

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

* feat(security): acknowledge misconfig findings

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* test(security): cover scanComposeStack failure modes

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

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

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

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

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

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

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

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

- Remove the dead fetchAllPages helper in routes/security.ts. It lost
  its callers when the SARIF endpoint switched to direct paged reads
  for the truncation cap. ESLint flagged it as unused.
- Switch the trivy-tmp-cleanup test helper to fs.mkdtempSync. Building
  paths under os.tmpdir() with predictable names tripped CodeQL's
  js/insecure-temporary-file rule (high severity), which warns about
  symlink-pre-creation attacks even in test code. mkdtempSync appends
  a process-random suffix and creates the dir atomically; the
  sencho-trivy- prefix is preserved so the production sweep still
  matches the test fixtures.
2026-05-07 19:23:11 -04:00
Anso 4b1de35dda Audit-hardening pass for fleet-replicated CVE suppressions (#976)
* perf(security): bucket CVE suppressions by id at read time

applySuppressions now builds a Map<cve_id, suppression[]> once before the
per-finding loop, dropping per-finding work to O(matching-cve-suppressions)
rather than O(all-suppressions). At a fleet-wide cap of 10000 rows against
a multi-thousand-finding scan, the prior linear-per-finding shape drifted
into tens of millions of comparisons per render.

Public API of findSuppression and applySuppressions is unchanged.
Specificity scoring, expiry handling, and image-glob matching are
preserved bit-for-bit. Adds a regression guard that pins a 10000x2000
workload under 1.5s and asserts at least one match was actually returned.

* feat(security): record audit-log entries on control-side CVE suppression CRUD

The replica receive path already wrote an audit-log entry on apply; the
control-side POST/PUT/DELETE handlers did not. Operators reading the
audit panel saw mirrored security-rule changes from the replica view but
could not see who originated them on the control. Symmetric logging
closes that gap.

The summary records the CVE id and pinned scope (pkg, image) but never
the suppression's reason text. Reasons are free-form admin input that
replicate fleet-wide and may carry incident-tracker IDs or vendor
context the operator did not intend to broadcast.

The summary is also sanitised before emission so an operator-supplied
package name or image pattern carrying a smuggled newline plus a forged
"cve_suppression.delete:" prefix cannot inject a fake row into the audit
panel. Control characters become "?" and the field is capped to its
validator length.

Adds a Control-side audit log block to suppression-routes.test.ts that
asserts the privacy contract on each verb (scope present, reason absent),
plus a log-injection guard test, plus a regression that GET still works
when the local instance is a replica.

* test(security): cover cve_suppressions fleet-sync receive path

The existing fleet-sync route tests covered the protocol mechanics
(auth, anchor, stale push, reanchor, demote) for cve_suppressions only
with empty-rows payloads. The suppression-specific concerns were
unverified end-to-end:

  - actual rows replace prior replicated rows on a fresh push
  - the receive path writes an audit-log entry naming the source
    fingerprint and row count
  - a malformed suppression row is rejected at the validator before any
    DB write

Adds a focused block exercising those three properties against the real
Express app, real SQLite, and the real apply transaction.

* docs(features): refresh CVE suppressions troubleshooting and field guidance

Converts the troubleshooting section to Mintlify Accordion blocks so
each entry is foldable and the page stays scannable. Adds entries for:

  - control-identity mismatch on a replica (anchor-aware reanchor flow)
  - mirrored rules persisting after a control demote
  - the 10000-row truncation cap on a fleet sync push

Adds a privacy note to the Reason field guidance: do not paste
credentials, tokens, or vendor secrets there, since the field replicates
fleet-wide and surfaces on every node's suppressions panel.
2026-05-07 16:18:19 -04:00
Anso a284732a95 feat(fleet-sync): hide other replicas' identity-scoped policies on a replica (#973)
GET /api/security/policies on a replica now returns only the policies
that apply to THIS replica. Replicated rows with a node_identity
targeting a sibling replica are filtered out so an operator cannot
enumerate the names and rules of policies meant for another node in
the fleet. Defense in depth: the security panel is admin-only, but a
backend filter is bypass-proof and matches Sencho's privacy posture.

Internal evaluators (getMatchingPolicy, evaluateScanAgainstPolicies)
keep using the unfiltered list because they already enforce identity
matching at evaluation time.

CVE suppressions are fleet-wide on every replica (no node_identity
column) so no analogous filter is needed.

Public surface:
- DatabaseService.getScanPoliciesForUi(role, selfIdentity): the
  filtered variant, called from securityRouter.get('/policies').

Tests:
- 3 new vitest cases: control sees full set; replica hides
  other-replica scoped rows; replica always includes locally created
  rows.
- Full backend suite: 1795 pass / 5 skipped.
2026-05-07 13:48:11 -04:00
Anso 4007709590 fix(fleet-sync): hygiene pass on receiver behavior and cleanup (#972)
A bundle of small file-local fixes to the receiver path and
node-deletion flow.

Changes:
- F4 receiver audit log: applyIncomingSync now writes a system audit
  entry on every applied push so mirrored security-rule changes show
  up in the replica's audit panel with a clear control-side origin.
- F7 pilot-agent skip: pushResource explicitly excludes pilot-agent
  nodes (they have no api_url for HTTP push) and warns once per node
  id so the operator sees they will not receive replicated policies.
- B4 identity-drift notification: when targetIdentity differs from
  the cached fleet_self_identity, dispatch a warning so the operator
  can audit any identity-scoped policies that may need re-targeting.
- B6 stack_pattern ReDoS guard: reject patterns with 4+ consecutive
  wildcards or more than 8 wildcards total. Both control-side
  validators (POST/PUT scan policies) and the receiver-side row
  validator share the helper.
- B9 deleteNode cascade: clear fleet_sync_status rows for the node
  inside the existing transaction so the sync-status panel does not
  render ghost entries after a node is removed.
- S6 last_error redaction: formatError strips Bearer tokens and
  JWT-shaped values from error messages and caps at 500 chars before
  storing in fleet_sync_status.last_error or logging.

Tests:
- 8 new vitest cases covering audit-log entry, identity-drift alert,
  pilot-agent warn-once, formatError redaction (Bearer + JWT), ReDoS
  validator rejection, and a backtracking-time smoke test.
- New database-fleet-sync-cascade.test.ts: deleteNode removes
  fleet_sync_status rows for the deleted node and leaves siblings
  untouched.
- Full backend suite: 1792 pass / 5 skipped.
2026-05-07 13:41:10 -04:00
Anso f8c75aa6cd fix(fleet-sync): clear stale policy_evaluation on replica sync swap (#971)
Replicated scan policies always insert with fresh ids on the replica
side. Any vulnerability_scans.policy_evaluation row referencing the
prior set was instantly stale the moment the swap completed, so the
replica's UI would surface violations from a policy that no longer
exists.

replaceReplicatedScanPolicies now calls clearOrphanPolicyEvaluations()
inside the same transaction as the delete + inserts, so the cleanup
commits atomically with the row swap. Local-only policies and their
cached evaluations remain untouched.

CVE suppressions do not have an analogous cache (suppressions are
applied at read time, never persisted on scan rows), so
replaceReplicatedCveSuppressions does not need a sibling call.

Tests:
- database-replicated-policies.test.ts: stale policy_evaluation
  cleared after a swap; local-policy evaluation preserved across
  swaps.
- Full backend suite: 1783 pass / 5 skipped.
2026-05-07 13:26:00 -04:00
Anso 33b15d6cba feat(fleet-sync): retry failed pushes and backfill on add-node (#970)
A control instance now retries fleet-sync pushes that hit a transient
failure and backfills the security state on a freshly registered remote
without waiting for the next policy edit.

New service:
- FleetSyncRetryService (singleton, start/stop) wakes 30s after boot
  and ticks every 5min. For each fleet resource, queries
  getFailedSyncTargets within a 24h window and re-pushes via
  FleetSyncService.pushResourceToNode through the same per-node mutex,
  so a normal fanout in flight serializes naturally with a retry.
- After STALE_THRESHOLD_MS (1h) of continuous failure for a
  previously-working node, dispatches a single warning notification
  per cooldown window. Brand-new nodes that have never succeeded do
  not alert via this path; misconfigured remotes are caught by the
  test-connection affordance at registration time.
- Wired into bootstrap startup/shutdown next to AutoHealService.

Public surface:
- FleetSyncService.pushResourceToNode(node, resource): targeted push
  to one node that re-uses the per-node mutex. Used by the retry
  service and any future targeted-resync flow.
- routes/nodes.ts POST /api/nodes fires pushResourceAsync for both
  resources after a remote-proxy node row commits.

Tuning constants centralized in fleetSyncConstants.ts:
- RETRY_MAX_AGE_MS = 24h
- STALE_THRESHOLD_MS = 1h

Tests:
- 8 vitest cases covering replica skip, retry dispatch, missing-node
  skip, alert-once-per-cooldown across the threshold window, no-alert
  for recent failures, no-alert for brand-new never-succeeded nodes,
  no-alert when the retry itself succeeds, start/stop idempotency.
- Full backend suite: 1781 pass / 5 skipped.
2026-05-07 13:16:24 -04:00
Anso 7dde257e1f feat(fleet-sync): replica self-demote endpoint and role UX (#969)
A replica admin can now demote the instance back to a standalone
control without raw SQLite access. The Settings → Security UI surfaces
a confirm-gated button when the role is replica; the role probe also
surfaces a soft banner when it cannot determine fleet role rather
than silently defaulting to control.

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

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

Tests:
- 4 new route-level vitest cases (401, 400 without confirm,
  end-to-end demote with replica setup, 409 ALREADY_CONTROL with
  explicit precondition).
- Service unit test asserts the consolidated clearReplicatedRows path.
- Full backend suite: 1773 pass / 5 skipped. Frontend: 185 pass.
2026-05-07 13:08:21 -04:00
Anso f3757b43c6 feat(fleet-sync): anchor replicas to a control fingerprint (#968)
A replica now binds to the first control that pushes to it. Subsequent
pushes from a different control are rejected with 409
CONTROL_IDENTITY_MISMATCH until an admin explicitly reanchors. Closes
the cross-control hijack window where any node_proxy bearer signed
against the replica's secret could overwrite security policies.

Wire protocol:
- Sender includes a stable 16-hex-char `controlIdentity` derived by
  SHA-256-truncating `system_state.instance_id` (the local UUID written
  once by LicenseService.initialize on first boot). Hostname rotations
  do not flag drift; only a SQLite reset or explicit reanchor breaks
  the binding.
- Receiver caches the fingerprint inside the same transaction that
  applies the row replacement and watermark write. Three states:
  null (fresh install), '' (post-reanchor), '<fingerprint>' (anchored).
- Empty `controlIdentity` is treated as legacy and accepted, so older
  controls keep working during rollout.

New endpoint:
- POST /api/fleet/role/reanchor (admin, requires `{override: true}`):
  clears the cached fingerprint, both `received_pushed_at:*` watermarks,
  and replicated rows of both resources, all in one transaction. The
  static cached fingerprint is also flushed defensively.

Public surface additions:
- `FleetSyncService.getControlIdentity()`: stable fingerprint for the
  outgoing push body.
- `FleetSyncService.reanchor()`: admin-driven anchor reset.
- `ControlIdentityMismatchError`: typed sentinel the route translates
  to 409 with structured body `{error, code, expected, got}`.

Tests:
- 9 new vitest cases covering first-sync persistence, mismatch
  rejection, matching acceptance, empty-incoming back-compat,
  post-reanchor un-anchored state, fingerprint stability, missing
  instance_id fallback, route-level mismatch, route-level reanchor
  with override gating.
- One ordered route-level scenario instead of cross-dependent it()
  blocks so test reordering cannot silently break the suite.
- Full backend suite: 1769 pass / 5 skipped.
2026-05-07 13:00:21 -04:00
Anso 27660f622b fix(fleet-sync): version the wire protocol and serialize per-node pushes (#967)
Hardens the scan-policy and CVE-suppression replication channel as the
foundation of a multi-PR fleet sync hardening track. No new endpoints,
no new tables, no schema changes; receivers still tolerate legacy
payloads (absent pushedAt and controlIdentity) for rollout safety.

Wire protocol:
- Sender stamps every push with a strictly-increasing pushedAt and a
  placeholder controlIdentity. Receiver rejects strictly-older pushedAt
  with 409 STALE_SYNC_PUSH so the next write retries.
- pushedAt comparison plus row replacement plus watermark write run in a
  single SQLite transaction; a partial-write window cannot leave the
  watermark behind the row state.

Concurrency and limits:
- Per-node mutex on the sender so concurrent control writes serialize
  per remote and never apply older state on top of newer.
- Sender-side row cap at MAX_SYNC_ROWS=5000 with a 6-hour throttled
  truncation alert so flapping configs cannot flood the operator.
- Route-level body limit raised to 5MB on POST /api/fleet/sync/:resource
  only; the global 100KB cap is unchanged. Oversize bodies return a
  structured 413 SYNC_PAYLOAD_TOO_LARGE.

Determinism and hygiene:
- getMatchingPolicy gains an id-ASC tiebreaker so two replicas resolve
  the same winner when policies tie on scope class.
- Inline comment documents why getMatchingPolicy filters node_id at SQL
  yet still relies on JS identity matching for replicated rows.
- Comment on the receive endpoint documents why no requirePaid is
  enforced (control's tier authorizes; replica trusts the bearer).
- STALE_SYNC_PUSH 409s no longer record a node failure; they are
  expected protocol outcomes, not health issues.

Public surface additions:
- DatabaseService.transaction(fn): generic SAVEPOINT-friendly wrapper.
- DatabaseService.getLocalScanPolicies / getLocalCveSuppressions: SQL
  filter on replicated_from_control = 0.
- StaleSyncPushError: typed sentinel the route translates to 409.
- fleetSyncConstants: shared MAX_SYNC_ROWS, body limit, state-key and
  error-code maps so the wire protocol has one source of truth.

Tests:
- 24 new vitest cases across fleet-sync-service, fleet-sync-routes, and
  database-matching-policy covering monotonic pushedAt, per-node
  serialization, row truncation and throttle, stale-push suppression,
  receiver back-compat, oversize-body 413, deterministic matching.
- Full backend suite: 1757 pass / 5 skipped.
2026-05-07 12:53:13 -04:00
Anso 0f0b22c51a feat(fleet): Fleet Secrets tab with env-var bundles (v1 MVP) (#965)
* feat(fleet): add Fleet Secrets tab with versioned env-var bundles (Skipper+)

Centralized, encrypted-at-rest secret bundles that can be pushed to labeled
nodes' stacks. Each save bumps a monotonic version; each push records a
per-node-per-version row in `secret_pushes` plus an entry in `audit_log`.
Conflict detection shows added/changed/unchanged/removed (informational)
diffs before write. Overlay merge preserves keys missing from the bundle.

- Adds `secrets`, `secret_versions`, `secret_pushes` tables.
- New `SecretsService` reuses CryptoService for AES-256-GCM, NodeLabelService
  for selectors, and direct fetch + Bearer for outbound calls to remote nodes.
- New `secretsRouter` with 9 endpoints under `/api/secrets`, gated by
  `requirePaid`. Mounted after the auth gate.
- Audit summary patterns added for the new routes.
- New Fleet › Secrets tab with bundle list, editor sheet (key=value rows,
  versions tab), and push wizard (selector, target stack, env file picker,
  per-node diff preview, results pills).
- Documentation: docs/features/fleet-secrets.mdx + docs.json nav entry.
- 26 Vitest cases cover parser, encryption, versioning, push aggregation,
  tier gating.

* fix(fleet): use const for rawValue in env parser

ESLint prefer-const flagged the let declaration as a CI-blocking error;
the variable is never reassigned.
2026-05-07 06:03:39 -04:00
Anso 52b46753af feat(fleet): add Federation tab with cordon and pin policy (Admiral) (#964)
Ships the v1 MVP for the Federation tab as placement control, not
placement automation:

- Cordon a node: marks the node unschedulable so the BlueprintReconciler
  skips it for new placements only. Existing deployments continue to
  drift-check and redeploy on revision changes; cordon never triggers
  withdraw or eviction. Toggle on the NodeCard kebab (Admiral, admin
  role); Cordoned pill renders for all tiers.
- Pin a blueprint to a node: stores blueprints.pinned_node_id, replacing
  the desired set with the pinned node regardless of selector. Pin
  overrides cordon by design. Action lives only in the Federation tab;
  BlueprintDetail and the deployment table show read-only Pinned
  indicators.

Backend: idempotent migrations add nodes.cordoned/cordoned_at/cordoned_reason
and blueprints.pinned_node_id. New routes POST /api/nodes/:id/cordon,
POST /api/nodes/:id/uncordon, PUT /api/blueprints/:id/pin, all gated by
requireAdmiral plus requireAdmin. Audit summaries added so the existing
auditLog middleware records every operator action. deleteNode clears
dangling pins.

Reconciler: pin override evaluated before selector match; cordon filter
applied only to the new-placement branch (deploy/stateReview without an
existing deployment). 11 new Vitest cases cover cordon filter, pin
override, pin-overrides-cordon, missing pin target, pin shrinks
desired set (stateless withdraw + stateful evict_blocked), and pin
clearing on node delete.

Frontend: new FederationTab.tsx with cordoned-nodes summary and
pin-policy table. Federation moved out of the experimental flag into
{isAdmiral && (...)} + AdmiralGate, mirroring the Routing tab pattern.
Secrets stays under experimental.

Tests pass: backend tsc, full Vitest suite (1704 passed), frontend
tsc -b, ESLint (0 errors). Manual verification via the local dev
instance confirmed the tab is hidden at Community, the kebab and pill
render at Admiral, and cordon and pin endpoints round-trip end to end.

Refs cut-line-1.0.md Federation v1 MVP.
2026-05-07 05:55:00 -04:00
Anso 77d5ff58d3 feat(fleet): add Fleet Actions tab for cross-node bulk operations (#963)
* feat(fleet): add Fleet Actions tab for cross-node bulk operations

Introduces a new "Actions" sub-tab in Fleet view with two Skipper+ cards
that fill gaps in the existing surface:

- Stop fleet by label: matches a label name across every node and stops
  every stack assigned to it, reporting per-node and per-stack results.
- Bulk label assign: applies the same label set to many stacks on one
  node in a single round trip.

Other bulk operations stay in their existing homes (sidebar bulk mode,
Schedules, NodeUpdatesSheet) to avoid duplicate surfaces.

Backend:
- POST /api/fleet/labels/fleet-stop (gateway-orchestrated, multi-node)
- POST /api/fleet-actions/labels/bulk-assign (per-node, capped at 1000)
- Tightens /api/fleet proxy-exempt prefix to /api/fleet/ so
  /api/fleet-actions/* is routed through the proxy for per-node calls.
- Exports activeBulkActions from labels.ts so fleet-stop and label-action
  share the per-node lock and cannot double-stop the same containers.
- Extracts containerActionForStack helper from stacks.ts for reuse.

* chore(fleet): rename Actions tab to Fleet Actions and reorder Fleet sub-tabs

- Tab label "Actions" -> "Fleet Actions" so the surface is unambiguous
  alongside Schedules and the sidebar bulk bar.
- Reorder Fleet sub-tabs as Overview / Snapshots / Status | Deployments /
  Traffic / Fleet Actions, with the separator after Status.
- Rename "Traffic · Routing" -> "Traffic" and update Sencho Mesh docs to
  match the shorter label.
- Update Fleet Actions docs to the new tab name and placement.
2026-05-07 05:41:53 -04:00
Anso 7fe90d9f3a feat(blueprints): capture compose snapshot before stateful eviction (#957)
Wire the snapshot_then_evict withdraw mode to actually persist the
blueprint's compose YAML to fleet_snapshots before running the
eviction. The mode previously recorded intent only. Capture failure
aborts the eviction with HTTP 500 rather than silently falling
through to a destructive withdraw.

Volume bytes remain out of scope: the snapshot holds the compose
definition only. UI copy and the Blueprints docs (Withdraw note,
Migrating stateful data section, two new Troubleshooting entries)
clarify that operators must move volumes by hand if they need the
data on another node.

Adds 9 route-level tests covering the success path, snapshot DB
write failure, orphan-row cleanup when insertSnapshotFiles fails,
empty compose_content, evict_and_destroy unchanged, stateless
unchanged, evict_blocked gate, omitted confirm field, and bad
confirm value.
2026-05-06 21:58:45 -04:00
Anso 72b6cdd0a3 fix: suppress ERROR logging for missing .env files in image update scan (#936)
* fix: suppress ERROR logging for missing .env files in image update scan

The ImageUpdateService logged a full ERROR stack trace for every stack
without a .env file, which is a normal and expected configuration.
Also added a 5-minute check timeout, developer_mode diagnostic logging,
and proper startup timeout cleanup.

* fix: add missing Node fields in test mock to satisfy tsc strict checking

* fix: remove unused variables to satisfy ESLint no-unused-vars
2026-05-06 11:21:37 -04:00
Anso ecf4dd5d52 feat: open security basics, manual fleet ops, and basic fleet management to Community (#930)
Realign tier guards to the user-stated philosophy: Community covers
deploy/monitor at scale plus security basics, Skipper adds automation
and advanced fleet management, Admiral keeps enterprise control.

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

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

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

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

Documentation across features/, api-reference/, and operations/ is
updated to reflect the new tier mapping.
2026-05-05 12:54:26 -04:00
Anso 49d775c61f feat(volumes): add read-only volume browser (#926)
* feat(volumes): add read-only volume browser

Adds a browser for the contents of any Docker named volume. Click the
folder icon on a volume row (admin only) to open a sheet with a directory
tree on the left and a file viewer on the right.

Backend
-------
New VolumeBrowserService spawns a one-shot Alpine 3.20 helper container
with the target volume mounted read-only at /v. The container runs as
nobody (65534:65534) with a read-only rootfs, no network, all caps
dropped, no-new-privileges, and capped at 64 PIDs and 128 MiB. The
helper image is pulled on first use per node.

Listing and stat use a portable busybox-compatible shell loop (find
-printf is not available on Alpine). Reads use head -c with an
explicit -- separator; the helper's working directory is /v so user
paths are passed as ./<path> argv elements and never as flags. The
container lifecycle is managed manually (create, attach, start, wait,
remove) to avoid the AutoRemove race where dockerode sees a 404 on
its post-exit container lookup.

Path safety: relative paths are sanitized server-side, rejecting
parent-escape segments, absolute paths, null bytes, and oversized
input. Symlinks are listed but never followed on read. Files larger
than 5 MB are truncated; binary content is detected via null-byte
scan and returned base64-encoded. Non-zero helper exits map to
404, 403, or 500 by classifying stderr.

Routes mounted at /api/volumes:
- GET /:name/list?path=
- GET /:name/stat?path=
- GET /:name/read?path=

All three require admin. The read endpoint always inserts an audit
log row (success or failure) with the actual response status code,
volume name, and relative path.

Frontend
--------
FileTree generalized to take a loadDir callback and a sourceKey
instead of a hard-coded stackName. The single existing consumer
(StackFileExplorer) was updated and its tests rewritten. The loader
is read through a ref so re-creating the arrow on every parent
render does not re-trigger the root fetch effect.

New VolumeBrowserSheet renders the tree against the volume API,
shows file content (hex view for binaries), and surfaces truncation.
Rapid sheet open and reopen on different volumes is generation-
checked to avoid stomping the visible result with a stale read.

A persistent footnote reminds the user that file reads are recorded
in the audit log, and the docs page warns about the typical contents
of database volumes.

Tests
-----
15 new vitest cases cover the pure helpers (path traversal, volume
name validation, binary detection). The Docker-facing exec path is
exercised by manual end-to-end via curl against a seeded volume.

* fix(volumes): truncate long volume names in browser sheet header

Wide volume names overlapped the close X. Reserve right padding on
the header, set min-w-0 on the flex title, mark the icon and refresh
button shrink-0, and truncate the name span.

* fix(volumes): satisfy lint on volume browser additions

prefer-const on sanitizeRelPath's local; drop unused FileTree entry
arg from the file-select callback (variance lets the arrow take fewer
params than the contract).
2026-05-05 00:09:40 -04:00
Anso 7e5dc2d9ea feat(resources): add image details sheet with layer history (#925)
Adds a read-only inspect panel for Docker images. Click the eye icon on
any image row to open a sheet showing:

- Overview: ID (with copy), size, created date, arch/OS, author, tags
- Config: Cmd, Entrypoint, WorkingDir, User, exposed ports, env (collapsible),
  labels (collapsible)
- Layers: ordered history list with size, age, and build command per layer.
  Empty layers (metadata-only) are dimmed.

Backend adds DockerController.inspectImage(id) which combines image.inspect()
and image.history() in parallel, exposed via GET /api/system/images/:id.
The route accepts both bare hex IDs and sha256-prefixed IDs, since the list
endpoint surfaces the prefixed form. Returns 400 for malformed IDs and 404
for missing images.

Documents the new panel in docs/features/resources.mdx under Images.
2026-05-04 23:45:54 -04:00
Anso 2e8ca76103 fix(backend): batch-insert stress test metrics to avoid per-insert fsync timeout (#911) 2026-05-04 13:36:01 -04:00
Anso e5b1c7b22b refactor(backend): collapse entitlement provider abstraction back to LicenseService (#889)
Removes backend/src/entitlements/ (registry, loadProvider,
CommunityEntitlementProvider, types, headers, normalize) and the two
abstraction-only tests. Relocates headers/normalize/types to
services/license-*.ts. Swaps 22 consumer call sites from
getEntitlementProvider() to LicenseService.getInstance(). Drops the
Dockerfile install step plus PRO_PACKAGE_VERSION build-arg and
github_token BuildKit secret in docker-publish.yml. Removes the now
stale no-restricted-imports rule in backend/eslint.config.mjs.

Net: 37 files changed, ~700 lines removed, no behavior change. Local
dev no longer requires GitHub Packages auth to start the backend.

Rationale and revisit conditions in
docs/internal/adrs/2026-05-02-collapse-entitlement-provider.md.
2026-05-02 23:45:44 -04:00
Anso d69fb9f1da feat(meta): gate deferred Fleet tabs behind SENCHO_EXPERIMENTAL flag (#886)
Hide the Traffic / Routing, Deployments, Federation and Secrets Fleet
tabs by default. They re-appear when the operator opts in by setting
SENCHO_EXPERIMENTAL=true. Backend routes and database tables are
unchanged; this is a UI discovery gate only.

The /api/meta endpoint now returns experimental as a boolean. A new
useExperimental hook reads it once per page load and feeds the four
tab triggers and tab content panels in FleetView.
2026-05-02 18:01:06 -04:00
Anso cffb481106 feat(entitlements): wire dynamic import of @studio-saelix/sencho-pro (#880)
Phase 2 of the open-core hybrid extraction documented in
docs/internal/adrs/2026-05-02-open-core-hybrid-strategy.md. The
private @studio-saelix/sencho-pro package is now published to GitHub
Packages with v0.1.0 carrying the LemonSqueezy implementation
(LemonSqueezyEntitlementProvider). This PR delivers the public-side
hookup so the loader prefers the private package when installed and
falls back to the in-tree LicenseService when not.

loadEntitlementProvider() tries `await import('@studio-saelix/sencho-pro')`
first. If the package is missing, the loader falls back to
LicenseService.getInstance() so a Community-only build (no private
package installed, e.g. local dev or the public BSL Docker image)
still runs through the existing LemonSqueezy path. If the package
loaded but threw during construction, or if a transitive dep is
missing, the loader re-raises so the failure surfaces; silently
downgrading a paid install to community on a load-time bug would be
a license-bypass surface.

The discrimination uses two checks rather than the error code alone:
the message must include the literal package name. Without that
anchor, a missing transitive dep in a paid install would surface
with the same MODULE_NOT_FOUND code as the package itself missing.
ERR_PACKAGE_PATH_NOT_EXPORTED is intentionally NOT classified as
"not installed" because that code fires when the package was
resolved but its exports map does not include the requested path,
which is a packaging bug worth surfacing.

backend/src/types/sencho-pro.d.ts is an ambient module stub so tsc
passes when the package is not installed locally. The package's own
dist/index.d.ts shadows the stub when present; drift fails the
build. The stub uses class implements EntitlementProvider so the
interface clause carries the full method surface; we do not
redeclare individual methods.

eslint.config.mjs adds a no-restricted-imports rule blocking static
imports of @studio-saelix/sencho-pro and any subpath. The loader's
await import() is a dynamic import and is not flagged. Static
imports would bundle the package into the public BSL build via
TypeScript's module resolution, defeating the privacy split, and
would break in Community-only environments.

Adds 7 unit tests for isProPackageNotInstalled covering all the
discrimination paths: non-Error inputs, the two recognized codes,
the package-name anchor, transitive-dep MODULE_NOT_FOUND, and
ERR_PACKAGE_PATH_NOT_EXPORTED.

Test results: 91/91 backend test files pass, 1665 passing tests, 5
pre-existing skips. tsc clean. eslint 0 errors.

Out of scope for this PR (Phase 2b, separate follow-up):
  - Dockerfile change to install @studio-saelix/sencho-pro from
    GitHub Packages using GITHUB_TOKEN auth.
  - docker-publish.yml building dual images: saelix/sencho
    (Community-only) and saelix/sencho-pro (with private package).

Out of scope for this PR (cleanup, separate follow-up):
  - Removing services/LicenseService.ts from the public repo.
  - Switching the loader fallback from LicenseService to
    CommunityEntitlementProvider.

The transitional state keeps the public repo runnable on its own
during the dual-image rollout window. The cleanup PR lands once
saelix/sencho-pro is verified working in production.
2026-05-02 06:07:13 -04:00
Anso 3324616e59 refactor(backend): extract EntitlementProvider abstraction (Phase 1) (#878)
* refactor(backend): extract EntitlementProvider abstraction (Phase 1)

Phase 1 of the open-core hybrid extraction described in
docs/internal/adrs/2026-05-02-open-core-hybrid-strategy.md. Introduces
the abstraction without moving any code out of the public repo; Phase
2 will actually move services/LicenseService.ts to a private
@studio-saelix/sencho-pro package.

The new backend/src/entitlements/ module contains:

- types.ts. The EntitlementProvider interface plus all tier/license
  types (LicenseTier, LicenseVariant, LicenseInfo, SeatLimits,
  ActivationResult, etc.). The interface mirrors the existing
  LicenseService public surface so the migration was mechanical.

- registry.ts. Module-scope holder for the active provider with
  setEntitlementProvider, getEntitlementProvider, and a test-only
  reset helper. getEntitlementProvider throws if called before
  bootstrap registers a provider; the throw is intentional fail-fast
  on a bootstrap-order bug rather than a silent degradation.

- CommunityEntitlementProvider.ts. Phase 2 fallback that returns
  community tier and rejects activate(). NOT instantiated in
  production today; a smoke test keeps it covered against bitrot.

- loadProvider.ts. Async resolver. Phase 1 returns
  LicenseService.getInstance() directly. The async signature matches
  what Phase 2 needs (dynamic import of @studio-saelix/sencho-pro
  with a "module not found" vs "construction threw" narrowing); the
  call site does not change between phases.

- headers.ts. PROXY_TIER_HEADER and PROXY_VARIANT_HEADER constants.
  These are part of the wire contract between Sencho instances and
  belong in the public core regardless of which entitlement provider
  is bound.

- normalize.ts. isLicenseTier, isLicenseVariant, normalizeTier,
  normalizeVariant. Domain knowledge about Sencho's tier model
  (legacy name maps from pre-0.38.1 versions), not LemonSqueezy
  internals. Phase 2 keeps these in the public core.

services/LicenseService.ts now imports its types from
entitlements/types and adds an "implements EntitlementProvider"
clause. Re-exports the types for back-compat with ~20 type-only
consumers; a follow-up PR will sweep those imports to entitlements/
types directly before Phase 2 deletes the file.

bootstrap/startup.ts awaits loadEntitlementProvider, registers the
result, then calls initialize. shutdown.ts calls
getEntitlementProvider().destroy() instead of the LicenseService
singleton.

middleware/tierGates.ts, the chokepoint for ~154 tier-check call
sites, now reads through getEntitlementProvider. Sixteen other
production files (routes/{fleet,imageUpdates,license,permissions,
scheduledTasks,security,stacks,templates,users,webhooks},
services/{BlueprintService,CloudBackupService,SchedulerService,
SSOService}, proxy/remoteNodeProxy, websocket/{hostConsole,
remoteForwarder}, middleware/auth) had their LicenseService.getInstance
calls and utility-export imports redirected to the entitlements
module. The only remaining LicenseService.getInstance in production
code is in entitlements/loadProvider.ts itself, which is the
intentional Phase-1 binding site.

Test infrastructure: setupTestDb registers
LicenseService.getInstance() as the active provider so existing
test files using the helper need no changes. The mocking pattern
many tests use, vi.spyOn(LicenseService.getInstance(), 'getTier'),
keeps working because LicenseService.getInstance() and
getEntitlementProvider() return the same singleton in Phase 1.
scheduler-service.test.ts is the only test that does not use
setupTestDb but exercises tier-gating; it now mocks
entitlements/registry alongside its existing LicenseService mock.

Adds a smoke test for CommunityEntitlementProvider so the Phase 2
fallback class stays covered.

Adds an architecture doc at
docs/internal/architecture/entitlement-provider.md covering the
runtime registry, bootstrap order invariants, and the Phase 1 vs
Phase 2 binding table.

Test results: 89/89 backend test files pass, 1657 passing tests, 5
pre-existing skips. The pre-existing database-metrics > handles
1000+ metrics stress test continues to flake under parallel load
and pass when re-run solo, same flake observed in PRs #862, #863.

* chore(backend): drop unused entitlement type imports from LicenseService

Phase 1 of the EntitlementProvider extraction left five type imports
(ActivationResult, BillingPortalError, BillingPortalResult,
DeactivationResult, ValidationResult) unreferenced after the runtime
methods that produced them began inferring their result shapes via the
EntitlementProvider interface contract. ESLint's no-unused-vars rule
flagged them as errors and failed the lint step in CI.
2026-05-02 05:07:00 -04:00
Anso d17562ac60 fix(license): reject activation when LS response is missing instance.id (#867)
LicenseService.activate() previously stored data.instance?.id || '' on
success. If LS ever returned activated:true without an instance.id
(malformed response, API change, transient bug), the user saw
"Activated successfully" but every subsequent validate() and
deactivate() short-circuited on the empty license_instance_id with a
generic "no active license" error. The activation appeared to succeed
while leaving the install in a broken state.

After the existing catalog-id guard, also require a non-empty
data.instance.id and reject up front with a retry-friendly message if
missing. The check costs nothing on the happy path (LS has historically
always returned instance.id on success) and turns a silent state
divergence into a clear, actionable error.

Adds two tests covering instance object absent and instance.id empty
string. Both assert mockSetSystemState was never called, which catches
any future code that accidentally writes state above the guard.

Adds a block comment above initialize() explaining the dual-name
relationship that confused the original audit: instance_id is the local
UUID we pass to LS as instance_name, license_instance_id is the
LS-issued activation id we pass back as instance_id on validate and
deactivate. Same area, swapped names, no overlap.
2026-05-02 00:39:41 -04:00
Anso 9f9e1bdff0 fix(license): verify LS store, product, and variant IDs in validate response (#862)
Lemon Squeezy's /v1/licenses/validate returns valid:true for any license key
in any LS store, so without a hardcoded catalog-identity check, a license
bought for any other LS product unlocks Sencho.

Add a module-level catalog map (store_id, two product_ids, six variant_ids)
and a pure resolveSenchoVariantFromMeta() helper. activate() and validate()
now reject responses whose meta does not match the Sencho catalog before
persisting any state. validate() additionally moves the license_last_validated
write below the catalog guard so a foreign-license refresh cannot extend the
offline grace window. getVariant() now resolves tier from variant_id first
(stable LS catalog identifier) and falls back to the substring match on
variant_name only when no variant_id is present.

Tests cover all 6 valid variants, every rejection branch, both activate and
validate paths, the no-DB-writes invariant on rejection, and the LS-side
key_status=expired/disabled branches.
2026-05-01 20:43:29 -04:00
Anso 685d5d729e feat(blueprints): backend foundation for fleet-wide compose templates (#860)
* feat(blueprints): add backend foundation for fleet-wide compose templates

Introduces the Blueprint Model: a docker-compose.yml plus a node selector
(labels or explicit IDs) that Sencho reconciles across the fleet. Backend
foundation only; the frontend tab and documentation follow.

Schema (DatabaseService):
- node_labels table for fleet-level orchestration tagging
- blueprints table with compose content, selector, drift_mode, classification
- blueprint_deployments table for per-node materialized state
- New idempotent migrate methods following the existing pattern

Services:
- BlueprintAnalyzer: pure compose-YAML classifier (stateless / stateful /
  unknown) with 17 covered cases including named volumes, bind mounts,
  external volumes, and tmpfs
- NodeLabelService: label CRUD plus selector matching helper (any/all/ids)
- BlueprintService: local + remote deploy/withdraw orchestration, marker
  file management, name-conflict guard, per-(blueprint,node) lock
- BlueprintReconciler: 60-second loop with three-mode drift policy
  (observe/suggest/enforce), state-aware guards, and Enforce-downgrade for
  volume-destroying drift

Routes (gated requirePaid + requireAdmin on mutations):
- /api/blueprints (CRUD + apply + withdraw + accept + preview + analyze)
- /api/node-labels (CRUD + listAll + listDistinct)

Notifications: four new categories registered in NotificationService for
deploy/failure/drift events.

Bootstrap: reconciler start/stop wired in startup and shutdown.

Tests: 45 new Vitest cases covering selector matching, classifier rules,
state-aware guards, drift-mode branching, and marker parsing. Full backend
suite (1625 tests) passes; tsc clean.

* fix(lint): replace bare Function type in blueprint reconciler tests

Replace 8 occurrences of `as unknown as { computeDecision: Function }`
with a properly typed `ReconcilerWithCompute` alias that mirrors the
real method signature. Export `ReconcileDecision` from
BlueprintReconciler so the test can reference it.

Resolves @typescript-eslint/no-unsafe-function-type errors that were
failing the Backend (Lint) CI step.
2026-05-01 18:57:44 -04:00
Anso 7663f4cd8b feat(fleet): sencho mesh in traffic and routing tab (#858)
* feat(fleet): sencho mesh in traffic and routing tab

Lights up Sencho Mesh: cross-node container forwarding rendered as if the
container next to you were on localhost. Builds on the dormant TCP frame
plumbing from the prior PR (pilot tunnel TCP frames + sencho-mesh sidecar
package) and exposes the Admiral-only orchestrator surface.

Backend
- New mesh_stacks table (per-node opt-ins) + nodes.mesh_enabled column
  via DatabaseService.migrateMeshTables.
- MeshService singleton: sidecar lifecycle via Dockerode, opt-in/out with
  cascading override regeneration, request-based resolver from sidecar
  control WS, cross-node TCP forwarding via PilotTunnelManager (same-node
  fast path included), in-memory 1000-event activity ring buffer with
  durable mirror to audit_log for state-change events, per-node and
  per-route diagnostics, and the Test upstream probe.
- MeshComposeOverride: pure YAML generator that injects extra_hosts using
  host-gateway. The user's docker-compose.yml is never mutated; overrides
  live under DATA_DIR/mesh/overrides.
- ComposeService deploy/update splice the override file when the stack
  is opted in; non-mesh stacks behave identically to today.
- Pilot agent resolveMeshTarget consults the local mesh_stacks table
  (defense in depth) and resolves Compose containers via Dockerode.
- /api/mesh router with 13 Admiral-gated endpoints covering status,
  enable/disable, stack opt-in/out, alias listing, per-route diagnostic,
  Test upstream probe, per-node diagnostic, sidecar restart, activity
  log paginated and SSE.
- meshControl WS slot at /api/mesh/control validates the mesh_sidecar
  JWT minted by MeshService; dispatched as upgrade slot 2 (canonical
  order preserved).

Frontend
- New Traffic Routing tab in FleetView, gated by isAdmiral and wrapped
  in AdmiralGate. Tab uses the cyan brand glyph and italic-serif state
  typography from the audit.
- RoutingTab masthead with mesh activity drawer, per-node card grid
  with TogglePill, alias rows with five-state pill taxonomy
  (healthy / degraded / unreachable / tunnel-down / not-authorized),
  inline Test buttons.
- Four sheets: opt-in picker with port-collision inline error,
  per-route detail with diagnostic + filtered activity, per-node
  diagnostics with active streams + resolver cache + restart action,
  fleet-wide activity log with filters.
- meshRouteState helper centralizes pill-state mapping; pure-function
  tests cover all five states.

Docs
- User docs at /docs/features/sencho-mesh.mdx covering opt-in,
  troubleshooting, security model (4 guarantees + 4 explicit
  non-guarantees), and V1 limitations.
- Internal architecture and runbook pages.
- websocket-dispatch internal doc updated with the new slot.

* fix(mesh): validate stack name before path use; fix test DB lifecycle

Two surgical fixes against the prior PR.

Path-injection (CodeQL js/path-injection): MeshService.optInStack,
optOutStack, ensureStackOverride, and removeStackOverride now validate
stackName via isValidStackName from utils/validation, reject malicious
names at the API boundary, and additionally check isPathWithinBase on
the resolved override file path for defense in depth. The dataflow from
req.params.stackName to fs.writeFile no longer reaches an unsanitized
path expression.

Test DB lifecycle: mesh-service.test.ts used per-test setupTestDb /
cleanupTestDb, which deletes the temp dir while DatabaseService still
holds an open SQLite handle. On Linux CI this raises
SQLITE_READONLY_DBMOVED on the next prepare() because the inode has
been unlinked. Switched to file-scoped beforeAll/afterAll matching
agents-routes.test.ts, with a per-test beforeEach that truncates
mesh_stacks plus non-default nodes and resets the MeshService singleton
in-memory state. Adds a new test case asserting the path-traversal
rejection.

* fix(compose): use discovered compose filename instead of hardcoded docker-compose.yml

composeArgs() hardcoded `-f docker-compose.yml` for every deploy. Sencho
writes its canonical compose file as `compose.yaml`, so any stack created
via the UI failed to deploy with `open ...docker-compose.yml: no such
file or directory`.

When no mesh override applies, drop the explicit `-f` so docker compose's
built-in discovery resolves the actual filename. When an override exists,
look up the real base filename via FileSystemService.getComposeFilename()
and pass both files explicitly.

Also hoist the MeshService import to module top now that the dependency
is known to be acyclic, and revert the matching unit-test assertion.
2026-05-01 01:50:53 -04:00
Anso 6893ece898 feat(pilot): add tcp tunnel frames + mesh sidecar package (#857)
Lays the dormant data-plane foundation for Sencho Mesh. The pilot tunnel
gains TCP forwarding frames (tcp_open / tcp_open_ack / tcp_close JSON
plus a 0x04 TcpData binary type) and a TcpStream surface on the bridge
so a future MeshService can ride the existing WSS tunnel for cross-node
container traffic. The agent rejects every tcp_open with mesh_not_enabled
until a follow-up PR wires the Dockerode resolver gated by a
mesh_stacks opt-in table; ships dormant.

A new top-level mesh-sidecar/ package provides the per-node container
that will host the L4 forwarder + control WS in production. Built as a
small Node 22 alpine image and published in lockstep with the main
sencho image via a parallel docker-publish workflow job.

Tests cover protocol roundtrips on both packages and the sidecar
forwarder end-to-end including resolve, splice, close, and stats.
2026-05-01 00:28:18 -04:00
Anso 3e01daf76f feat(stack): per-stack activity timeline with actor attribution (#852)
* feat(stack): per-stack activity timeline with actor attribution

Adds an Activity tab to the Stack Anatomy panel showing a timestamped
event log for each stack: deploys, restarts, starts, stops, and image
updates, attributed to the user who triggered them or 'system' for
automated actions.

Backend:
- Extends notification_history with actor_username column (idempotent
  migration) and a partial composite index on (node_id, stack_name,
  timestamp DESC) for efficient per-stack lookups.
- NotificationService.dispatchAlert() accepts an optional actor that
  is written to the new column.
- Success-side dispatchAlert calls added after deploy, bulkContainerOp
  (start/stop/restart), and update handlers in routes/stacks.ts so
  user-initiated operations are recorded, not just failures.
- New GET /api/stacks/:stackName/activity?limit&before endpoint with
  stack:read permission gate and cursor-based pagination.

Frontend:
- StackAnatomyPanel grows an Anatomy / Activity tab pair using the
  existing Tabs primitive.
- StackActivityTimeline fetches the initial 50 events, paginates on
  demand, and prepends live events arriving over the existing WS
  notifications stream without duplicates.
- NotificationPanel bell dropdown suppresses user-initiated success
  events (start/stop/restart/deploy/update triggered by a real user),
  keeping the tray focused on alerts and system events.

* docs(stack): add stack activity timeline feature page and internal arch docs

* fix(test): add actor_username to notification-routing history assertions

dispatchAlert now passes actor_username to addNotificationHistory after
the activity timeline PR added the column. Update the two exact-match
assertions that were failing because the expected object shape was missing
this field.
2026-04-30 19:53:23 -04:00
Anso 2000653fb4 perf(test): build baseline DB once via vitest globalSetup (#829)
Each test file's setupTestDb() previously re-ran the full
DatabaseService init path: initSchema (~30 CREATE TABLE IF NOT
EXISTS), 14 idempotent migrate*() methods, a bcrypt hash, and the
admin / settings seed inserts. With 82 files this was a meaningful
slice of the per-fork cold-start cost.

Move the build into a vitest globalSetup that runs once before any
worker boots. The baseline DB lands at a fixed temp path; each
worker's setupTestDb copies it into the per-file data dir, opens the
copy via DatabaseService.getInstance() (re-running the same
idempotent init as a no-op pass), then UPDATEs the seeded local
node's compose_dir to match the per-file COMPOSE_DIR (the baseline
recorded /app/compose because COMPOSE_DIR was unset when the seed
fired in initSchema; without realigning, file-routes tests 400 on
path traversal).

TEST_JWT_SECRET moves from a per-file randomBytes assignment to a
fixed constant in a new testConstants module so the value the
baseline seeds matches the value test files import for direct token
signing. setupTestDb re-exports it for back-compat with the existing
import sites.

A baseline-less measurement on the same machine flakes 30 of 82
files at the no-cap baseline; with this baseline copy, the same
tree drops to 0-3 failures (the residual environmental Windows
flakes) and ~47-52 s wall time.
2026-04-28 10:08:39 -04:00
Anso 279ec62dff perf(backend): replace docker system df shell-out with dockerode API (#818)
MonitorService.evaluate() forked the docker CLI every 30s and
walked the human-readable Reclaimable strings ("1.196GB", etc.)
with a regex to compute the janitor threshold check. The Docker
Engine API returns raw byte counts, and the existing
DockerController.getDiskUsage() already wraps it for images,
containers, and volumes. Extend that helper with reclaimable
build-cache bytes so MonitorService can sum the four categories
in one call.

Drops the child_process / promisify imports from MonitorService and
removes about 30 lines of stdout parsing. Also widens the explicit
return type of getDiskUsageClassified so the new fields aren't
silent runtime additions.
2026-04-28 01:16:07 -04:00
Anso add3abaece refactor(backend): replica guard helper for security routes (#797)
* refactor(backend): extract replica guard helper for security routes

Adds blockIfReplica(res, resource) in middleware/fleetSyncGuards.ts and
replaces six inline FleetSyncService.getRole() === 'replica' checks
across the security policies and CVE suppressions endpoints.

Error responses now use a uniform shape:
  403 { error: 'Cannot modify <resource> on a replica instance.
    Connect to the primary.', code: 'REPLICA_READ_ONLY' }

The new code field gives callers a stable discriminator without
matching prose.

Closes #750

* test(suppressions): match stable REPLICA_READ_ONLY code instead of prose

The replica guard helper exposes a stable code field for callers to
discriminate without grepping the human-readable error string. Switch
the replica-rejection assertion to use that code so the test no longer
breaks when the unified error template wording is tuned.
2026-04-27 00:29:38 -04:00
dependabot[bot] 71d164cf9e chore(deps): bump the all-npm-backend group across 1 directory with 10 updates (#783)
* chore(deps): bump the all-npm-backend group across 1 directory with 10 updates

Bumps the all-npm-backend group with 10 updates in the /backend directory:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-ecr](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ecr) | `3.1028.0` | `3.1037.0` |
| [axios](https://github.com/axios/axios) | `1.15.0` | `1.15.2` |
| [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) | `12.8.0` | `12.9.0` |
| [express-rate-limit](https://github.com/express-rate-limit/express-rate-limit) | `8.3.2` | `8.4.1` |
| [openid-client](https://github.com/panva/openid-client) | `6.8.2` | `6.8.3` |
| [otplib](https://github.com/yeojz/otplib/tree/HEAD/packages/otplib) | `12.0.1` | `13.4.0` |
| [eslint](https://github.com/eslint/eslint) | `10.2.0` | `10.2.1` |
| [typescript](https://github.com/microsoft/TypeScript) | `6.0.2` | `6.0.3` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.58.1` | `8.59.0` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.4` | `4.1.5` |



Updates `@aws-sdk/client-ecr` from 3.1028.0 to 3.1037.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.1037.0/clients/client-ecr)

Updates `axios` from 1.15.0 to 1.15.2
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.15.0...v1.15.2)

Updates `better-sqlite3` from 12.8.0 to 12.9.0
- [Release notes](https://github.com/WiseLibs/better-sqlite3/releases)
- [Commits](https://github.com/WiseLibs/better-sqlite3/compare/v12.8.0...v12.9.0)

Updates `express-rate-limit` from 8.3.2 to 8.4.1
- [Release notes](https://github.com/express-rate-limit/express-rate-limit/releases)
- [Commits](https://github.com/express-rate-limit/express-rate-limit/compare/v8.3.2...v8.4.1)

Updates `openid-client` from 6.8.2 to 6.8.3
- [Release notes](https://github.com/panva/openid-client/releases)
- [Changelog](https://github.com/panva/openid-client/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/openid-client/compare/v6.8.2...v6.8.3)

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

Updates `eslint` from 10.2.0 to 10.2.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.2.0...v10.2.1)

Updates `typescript` from 6.0.2 to 6.0.3
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v6.0.2...v6.0.3)

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

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

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-ecr"
  dependency-version: 3.1037.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: axios
  dependency-version: 1.15.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: better-sqlite3
  dependency-version: 12.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: express-rate-limit
  dependency-version: 8.4.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: openid-client
  dependency-version: 6.8.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: otplib
  dependency-version: 13.4.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: all-npm-backend
- dependency-name: eslint
  dependency-version: 10.2.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: typescript
  dependency-version: 6.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: typescript-eslint
  dependency-version: 8.59.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: vitest
  dependency-version: 4.1.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix(mfa): migrate otplib API to v13

The v13 release removed the singleton authenticator export and renamed
HashAlgorithms to a string union type. Switch to the OTP class with
generateSync/verifySync for synchronous operation, passing per-call
options instead of setting global instance state.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: SaelixCode <dev@saelix.com>
2026-04-26 16:24:03 -04:00
Anso 03f91cd5bb feat(cloud-backup): mirror fleet snapshots to S3-compatible storage (#782)
* feat(cloud-backup): mirror fleet snapshots to S3-compatible storage

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

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

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

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

The backend tsconfig pins lib to ES2020. The two-argument
`Error(message, { cause })` form requires ES2022, so tsc rejected it
with TS2554. Revert to single-argument throw to match the
convention used elsewhere in the backend services.
2026-04-26 15:42:21 -04:00
Anso 801a098a5b feat(files): per-stack file explorer (#780)
* feat(files): backend foundation for stack file explorer

Install multer for multipart file upload handling. Add
isValidRelativeStackPath to validation.ts to guard client-supplied
relative paths against traversal, absolute paths, NUL bytes, backslash
injection, and double-slash segments. Add isBinaryBuffer to a new
binaryDetect.ts utility for heuristic text/binary detection via
NUL-byte fast exit and non-printable byte ratio sampling.

* fix(files): reject bare dot segments in isValidRelativeStackPath

* feat(files): add safe stack-scoped file I/O methods to FileSystemService

Adds FileEntry interface and seven new public methods to FileSystemService
for stack-scoped file operations: listStackDirectory, readStackFile,
streamStackFile, writeStackFile, deleteStackPath, mkdirStackPath, and
statStackEntry.

Each method routes through a private resolveSafeStackPath helper that
enforces two-phase path containment: a pre-realpath lexical check plus a
post-realpath symlink-escape check. ENOENT targets are handled by walking
up to the deepest existing ancestor, realpaths that ancestor, and
reattaching the remaining suffix.

Binary detection delegates to isBinaryBuffer; path safety delegates to
isPathWithinBase. Protected file names and the MIME map are module-level
constants to avoid repeated allocation.

* feat(files): frontend API wrappers and Monaco language helper

* fix(files): tighten stackFilesApi error handling and localOnly support

* fix(files): FileSystemService safety and correctness fixes

* feat(files): add file explorer API endpoints to stacks router

* feat(files): FileTree and FileTreeNode components

* fix(files): route security hardening and stream cleanup

* fix(files): FileTree accessibility, icon stroke, stale fetch guard

Add strokeWidth={1.5} to all Lucide icons in FileTreeNode to match the
design system. Add aria-expanded to directory rows for accessibility.
Guard handleDirClick .then() callbacks against stale stack name
references when the component re-renders with a new stack. Add
toast.info fallbacks when compose.yaml or .env is clicked without a
navigation callback registered.

* feat(files): FileViewer, FileUploadDropzone, NewFolderDialog, DeleteFileConfirm

* fix(files): resolve code quality findings in file explorer components

- Move editorOptions useMemo above conditional returns in FileViewer (Rules of Hooks fix)
- Fix blob download: append anchor to DOM before click, defer URL revoke 100ms
- Keep protected-file confirm input visible during NOT_EMPTY recursive retry in DeleteFileConfirm
- Remove non-functional cursor-pointer/onClick from Community upgrade pill in FileUploadDropzone
- Add success toast on folder creation in NewFolderDialog
- Switch all (e as Error).message casts to instanceof Error narrowing

* test(files): unit tests for binary detection, stack path safety, and file explorer routes

- binary-detection.test.ts: covers isBinaryBuffer edge cases (empty, NUL,
  PNG header, threshold boundary, sampleBytes parameter)
- filesystem-stack-paths.test.ts: covers isValidRelativeStackPath (accepts/
  rejects matrix) and FileSystemService stack methods against a real temp dir
  (listStackDirectory sort and protection flags, readStackFile text/binary/
  oversized paths, writeStackFile/Buffer, deleteStackPath, mkdirStackPath,
  traversal guard); platform-specific empty-dir/NOT_EMPTY cases skip on Windows
- stack-files-routes.test.ts: route-level integration tests for all seven
  file explorer endpoints; covers auth gating, Community-tier 403 gates,
  input validation, 413 TOO_LARGE upload limit, and 204/200 happy paths

* feat(files): StackFileExplorer container with lazy tree, viewer, and action bar

* fix(files): add Download button to explorer toolbar, fix Community upgrade pill, reset state on stack change

* test(files): add missing test coverage for file explorer routes and service

* feat(files): add Files tab to EditorLayout with StackFileExplorer integration

* fix(files): add defensive activeTab guard to saveFile and discardChanges

* test(files): unit tests for FileTree expand/collapse and FileViewer render modes

Covers the three FileViewer content modes (text/Monaco, binary panel,
oversized panel) and the FileTree expand/collapse/cache cycle: first
expand fetches the subdirectory, second click collapses without a fetch,
third click re-expands from the in-memory cache without a second fetch.

* test(e2e): file explorer community and skipper+ flows

Covers the full file-explorer feature surface in two describe blocks:

Community (read-only): intercepts /api/license to simulate community
tier, confirms the upgrade pill is visible in the left pane, and
asserts that the Save button is absent after opening a text file.

Skipper+ (full CRUD): uploads a text file and confirms it appears in
the tree; edits config/app.conf and saves via Monaco; deletes an
uploaded file and asserts the tree entry is gone; issues a raw HTTP
request to the download endpoint and checks for status 200 and the
content-disposition: attachment header.

Also adds data-testid="file-action-delete" to the action bar Delete
button in StackFileExplorer for stable targeting, and exports
waitForStacksLoaded from e2e/helpers.ts to eliminate the three
identical local copies in stacks, deploy-log-panel, and stack-files
spec files.

* fix(e2e): improve test isolation and selector stability in stack-files spec

Move beforeEach seed to beforeAll/afterAll so fixtures are created once per
suite, not before every test. Extract shared seedSuite/teardownSuite helpers
to eliminate the duplicate beforeAll/afterAll blocks. Wrap teardown in
try/catch so failures log a warning rather than masking test results.

Replace waitForTimeout(500) with a deterministic expect on the file tree
sentinel. Add data-testid="anatomy-files-btn" and data-testid="delete-confirm-btn"
to replace the fragile button text/positional selectors. Assert Save button
starts disabled before editing.

* docs(files): add stack file explorer documentation

Add user-facing guide for the stack file explorer feature covering
tier access (Community read-only, Skipper+ read-write), viewing
limits, upload/download caps, protected file routing, and
troubleshooting. Update the editor page to reference the new guide
and register the page in the navigation.

* fix(docs): use canonical Skipper tier name in file explorer overview card

* fix(files): resolve lint errors blocking CI

Remove unnecessary backslash escape before double-quote in the
Content-Disposition regex (no-useless-escape). Replace five synchronous
setState resets at the top of the FileTree mount effect with a React key
prop on the FileTree element in StackFileExplorer so remounting resets
state automatically, eliminating the react-hooks/set-state-in-effect
violation.

* test(files): fix e2e seeding to work on community-tier CI

Replace the browser-side paid upload/mkdir API calls in seedTestStack with
direct Node fs writes. The upload and folder endpoints require Skipper+ so
they returned 403 on CI, which runs with no license set. Stack creation
via POST /api/stacks stays as an API call since it is community-allowed and
keeps the backend registry in sync.

Add a per-test tier check in the Skipper+ beforeEach that skips gracefully
when the instance is community, matching the pattern in auto-heal-policies.
2026-04-26 13:05:19 -04:00
Anso dd9d33813b feat(deploy-logs): opt-in deploy progress modal with structured log rows (#779)
* feat(notifications): dispatch deploy_failure alert on stack action errors

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Now the login branch waits up to 2s for #username to actually appear
before filling. If it never appears, we fall through to the dashboard
check instead of hanging.
2026-04-26 00:43:54 -04:00
Anso 6986b927e3 feat(stacks): per-service start/stop/restart lifecycle actions (#778)
* feat(stacks): add per-service start/stop/restart lifecycle routes

Adds POST /:stackName/services/:serviceName/{start,stop,restart} routes
that operate on containers belonging to a single Compose service, using
the same Engine API pattern as the existing stack-level lifecycle routes.
Includes isValidServiceName validator and audit-summary entries for the
three new paths.

* test(stacks): add per-service action route tests

* test(stacks): fix test quality issues in service action tests

* feat(stacks): add per-service lifecycle menu to container cards

* fix(stacks): handle paused container state in service action menu

* docs(stacks): add per-service lifecycle actions documentation

* docs(stacks): add validation screenshots for per-service lifecycle actions
2026-04-25 17:26:04 -04:00
Anso abee078741 feat(scheduler): add auto_backup, auto_stop, auto_down, auto_start and delete_after_run one-shot mode (#777)
* feat(scheduler): add auto_backup, auto_stop, auto_down, auto_start actions and delete_after_run one-shot mode

Extends the scheduler with four new stack-targeted actions:

- auto_backup: backs up stack compose files and .env using the existing
  FileSystemService.backupStackFiles primitive
- auto_stop: runs compose stop (containers preserved)
- auto_down: runs compose down (containers removed)
- auto_start: runs compose up -d via deployStack (universal start for both
  stopped and down stacks)

Adds delete_after_run boolean column to scheduled_tasks. When enabled,
the task self-deletes after its first successful execution; failures keep
the task so the user can debug and retry.

All four new actions gate at Admiral tier, consistent with restart/snapshot/prune.
Migration is idempotent (maybeAddCol).

* docs(scheduler): update scheduled-operations doc with new lifecycle actions and delete-after-run

Adds the four new actions (Backup Stack Files, Stop Stack, Take Stack Down,
Start Stack) to the action table. Documents the delete-after-run one-shot mode
with its success-only deletion semantics. Adds the Stack Lifecycle Scheduling
section explaining stop-vs-down semantics and the local-execution boundary.
Adds three troubleshooting entries: auto-start on a missing compose folder,
auto-backup single-slot overwrite by design, and one-shot task disappearing
after successful run.

Updates the timeline description from four to five lanes. Refreshes
screenshots to show the new dialog layout with the Lifecycle lane visible.
2026-04-25 16:11:13 -04:00
Anso e0034132b4 feat(notifications): match routing rules by labels and categories (#776)
- Add label_ids and categories columns to notification_routes via idempotent migration
- Matcher logic always evaluates routes (AND semantics across all non-empty matchers)
- getStackLabelIds skips DB call when no enabled route uses label filtering
- Extract ALL_NOTIFICATION_CATEGORIES array from NotificationService as single source of truth
- Derive VALID_CATEGORIES set from the array in the route handler
- Extract validateLabelIds and validateCategories helpers to remove POST/PUT duplication
- Extract tryAddColumn as a private DatabaseService class method (removes 5 local re-declarations)
- Extract CATEGORY_LABELS to frontend/src/lib/notificationCategories.ts (shared by NotificationPanel and NotificationRoutingSection)
- Frontend form adds label and category multiselects with AND-filter hint
- Route cards show label and category badges; empty-matcher routes show 'Matches all alerts'
- Add tests for category-only, label-only, and combined AND-semantics routing
2026-04-25 14:58:50 -04:00
Anso fcbdd59ec2 fix(notifications): scope routing rules to nodes via node_id column (#775)
Adds a nullable node_id column to notification_routes (null = any
node, integer = fire only when the alert originates from that specific
node). This fixes a multi-node fleet defect where a route scoped to
"my-app" would fire on every node that hosts a stack with that name.

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

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

Tests: 3 new tests covering node-match, node-mismatch, and null-scope;
all 75 files (1413 tests) passing.
2026-04-25 13:56:48 -04:00
Anso 44dba59cab feat(notifications): add structured category enum to dispatcher and history (#774)
Introduce a NotificationCategory string-literal union (11 values) and
thread it through dispatchAlert as a required second argument. All
callers (DockerEventService, AutoHealService, ImageUpdateService,
MonitorService, PolicyEnforcement, policyGate, SchedulerService,
imageUpdates route) pass an explicit category at every call site,
giving TypeScript compile-time enforcement that no new emit site can
be added without choosing a category.

DatabaseService gains an idempotent migration that adds a nullable
category TEXT column to notification_history; existing rows keep
category=NULL (displayed as Uncategorized in the UI). The
getNotificationHistory method accepts an optional category filter
that is forwarded from the GET /api/notifications/history route via
a ?category= query param.

NotificationPanel gains a category Select dropdown so users can
filter history by category. The frontend types mirror the backend
union so API responses are type-safe end-to-end.

All 75 test files (1410 tests) updated to the new 4-arg dispatchAlert
signature and passing.
2026-04-25 13:55:07 -04:00
Anso a74564fd61 feat(scheduler): support fleet-wide auto-update schedules per node (#773)
Allow a scheduled task with action='update' and target_type='fleet'
to update every eligible stack on a node in a single schedule entry.

The executor respects each stack's per-stack auto-update policy via a
single batch query, skipping stacks that have opted out. For remote
nodes the request proxies to the remote Sencho instance, which already
enforces the same policy in its /api/auto-update/execute endpoint.

Backend route validation now accepts update+fleet as a valid combo
(previously only update+stack was allowed) and requires node_id.

Frontend adds an "Auto-update All Stacks" option to the scheduled-task
creation form with a node selector and descriptive helper text.
2026-04-25 12:17:09 -04:00
Anso af9cb0aa63 feat(auto-update): per-stack auto-update enable/disable toggle (#771)
* feat(auto-update): add per-stack auto-update enable/disable toggle

Paid users (Skipper and Admiral) can now opt individual stacks out of
scheduled auto-updates from the stack context menu without disabling
the global feature.

- Add stack_auto_update_settings table (node_id, stack_name) with
  default enabled=true; four typed DatabaseService accessors with
  parameterized queries.
- Add GET /stacks/auto-update-settings, GET /stacks/:name/auto-update,
  and PUT /stacks/:name/auto-update (requirePaid + requireAdmin).
  PUT broadcasts state-invalidate with action auto-update-settings-changed
  so all open tabs refresh immediately.
- Stack DELETE clears the auto-update setting row alongside stack_update_status.
- autoUpdateRouter /execute skips disabled stacks before any registry
  call; skip is recorded in the results array. Manual Update actions
  are not affected.
- Add Auto-update: Enabled/Disabled toggle in the stack inspect group
  (paid tiers only, hidden for Community, consistent with Auto-Heal).
  Toggle uses optimistic update with revert-on-error toast.
- AutoUpdateReadinessView shows an Auto: Off pill and disables the
  Apply now button for stacks with auto-updates off. Detection still
  runs so the readiness card remains visible.
- Add 21 backend Vitest tests covering DB round-trips, endpoint auth
  and tier gates, execute skip for both wildcard and named targets.
  Add 3 frontend hook tests for toggle visibility and callback behavior.

* docs(auto-update): document per-stack auto-update control

Add a Per-stack control section to the auto-update readiness page
explaining how to disable and re-enable auto-updates for individual
stacks, what disabling means (scheduled apply skipped; detection still
runs; manual update unaffected), and a troubleshooting entry for
scheduled runs not applying to a specific stack.
2026-04-25 10:50:21 -04:00
Anso 58df1a50b3 feat(auto-update): show pending image updates fleet-wide on the Auto-Updates page (#770)
Group readiness cards by node so updates pending on every reachable node
are visible without having to switch the active node. Apply now targets
the owning node directly, and Recheck fans out to every reachable node
in parallel; per-node cooldowns are surfaced in the toast.

Adds POST /image-updates/fleet/refresh and invalidates the fleet
aggregation cache after auto-update execute so the next read reflects
the new state immediately. A small banner appears under the hero when
some online nodes did not respond within the request timeout.
2026-04-25 08:21:50 -04:00
Anso 5c5021846a feat(events): broadcast state-invalidate on docker events so dashboard updates live (#768)
Dashboard and sidebar status indicators previously only refreshed on a
5-30 second polling cadence: a container restart, a degraded -> healthy
transition, or a stack update was invisible until the next tick.

Add a lightweight, non-persisted "state-invalidate" envelope on the
existing /ws/notifications WebSocket:

Backend
- NotificationService.broadcastEvent: sibling of dispatchAlert that
  pushes an arbitrary {type, ...} envelope to every subscriber WITHOUT
  writing to the alerts history (these are pure ephemeral signals).
- DockerEventService.handleEvent: emit the envelope for state-changing
  container actions (start/die/kill/destroy/create/restart/pause/
  unpause/health_status/rename/update). Carries node id, stack name
  (from the compose project label), container id, action, and
  timestamp.

Frontend
- EditorLayout's two notification WebSocket handlers (local plus
  per-remote-node) branch on type. On state-invalidate they re-emit a
  window CustomEvent and trigger a debounced (250ms) refreshStacks so
  a burst of events from compose recreating multiple services
  collapses to one refetch. The refresh callback is held in a ref so
  the long-lived WS effect never closes over a stale function.
- useDashboardData listens for the same window event and refetches
  /stats, /system/stats, and /stacks/statuses on every signal.
  Historical metrics stay on their 60s polling cadence (10-minute
  trend data, not a live indicator).

Tests
- Three new docker-event-service cases assert broadcastEvent fires on
  start and health_status events with the correct envelope shape, and
  does not fire on non-state actions like exec_create.
- Existing 28 cases updated with the broadcastEvent mock so the
  subscriber stub matches the new shape.

Polling stays as a safety net at the same intervals; the WS path is
the fast path. Multi-node fleets benefit on the local node today;
extending the remote forwarder to relay state-invalidate is a
recommended follow-up.
2026-04-24 23:38:08 -04:00
Anso 584cda7182 fix(auto-update): label same-tag rebuilds as 'Rebuild available' instead of '10.11 -> 10.11' (#766)
When a registry pushes a new build of an image at the same tag (digest
changes, tag does not), the preview service set next_tag to the same
string as current_tag and the readiness view rendered '10.11 -> 10.11',
which reads as a UI bug.

Add an update_kind field to UpdatePreviewSummary that distinguishes:
- 'tag'    - at least one image has a strictly newer tag
- 'digest' - the only updates available are same-tag rebuilds
- 'none'   - nothing to apply

The frontend now branches on update_kind and renders 'Rebuild available'
next to the current tag for the digest case, leaving the version-arrow
diff for genuine tag bumps.

Three new buildSummary cases lock in the kind classification.
2026-04-24 23:37:32 -04:00
Anso 24c0a2833b fix(security): clear cached policy evaluations when a scan policy is deleted (#758)
Vulnerability scans cache their policy verdict as a JSON blob in
vulnerability_scans.policy_evaluation. Deleting a scan policy used to
remove only the policies row and leave those blobs intact, so the
scheduler kept emitting violations and stacks remained marked as blocked
against a policy that no longer existed.

deleteScanPolicy now nulls out policy_evaluation on every scan whose
JSON references the deleted policy id, then deletes the policy row, in
one transaction.
2026-04-24 22:28:25 -04:00
Anso ed553f1f19 feat: change default listen port from 3000 to 1852 (#756)
Updates the backend listen port, Vite dev proxy target, Docker EXPOSE,
compose port mapping, .env.example default, GitHub Actions smoke-test
default, healthcheck URLs, and every doc/example reference. Test fixtures
that include example URLs were updated for consistency, though their
assertions are port-agnostic.

The rate-limit value of 3000 in middleware/rateLimiters.ts and the
3000 entry in WEB_UI_PORTS (which detects user containers like Grafana)
are intentionally untouched.
2026-04-24 22:23:31 -04:00
Anso d6b744e8e6 feat(license): replace local auto-trial with Lemon Squeezy hosted trial flow (#755)
Fresh installs land on the Community tier. The 14-day Admiral trial is now
issued by Lemon Squeezy via their hosted checkout: the user enters email +
card, receives a license key by email, and pastes it into the existing
Settings > License activation field.

Backend changes:
- LicenseService.initialize() no longer auto-creates a license_status='trial'
  row on first boot. It now only ensures an instance_id exists and starts
  periodic validation.
- Drop the TRIAL_DURATION_DAYS constant.
- Drop the status='trial' early-return in getVariant() so LS-issued trials
  resolve through the normal variant metadata path (variant_name / product_name).
- Trial branches in getTier() and getLicenseInfo() are retained for future
  work that may detect trial state from Lemon Squeezy metadata; they are
  currently unreachable via the Sencho code paths.

Frontend changes:
- Settings > License surfaces a new "Try Admiral free for 14 days" CTA block
  with Start monthly trial and Start annual trial buttons that open Lemon
  Squeezy hosted checkout. The CTA is visible only when the user has no paid
  access and is not already on a trial.
- Reserve the Admiral upgrade card for the Skipper-active upgrade path so
  unlicensed users see one Admiral path (the trial CTA) instead of two.
- Pull the inline Lemon Squeezy checkout URLs into named module constants so
  the Skipper, Admiral monthly, and Admiral annual endpoints are defined in
  one place.

Test changes:
- license-service.test.ts covers the no-auto-trial startup path and updates
  the trial-variant test to reflect the metadata-driven resolution.
- afterAll in the initialize() describe block calls destroy() so the 72-hour
  validation interval does not leak into sibling test files.

Docs:
- Rewrite the Free trial section in features/licensing.mdx to document the
  new LS checkout flow (email + card required, auto-converts on day 14 unless
  cancelled).
- Add an operations/troubleshooting entry for cases where the trial license
  key email does not arrive.
2026-04-24 16:26:36 -04:00
Anso a502da54ee feat(sso): split SSO providers by delivery model across tiers (#754)
Custom OIDC stays on Community so self-hosters can wire any spec-compliant
OIDC identity provider (Authelia, Keycloak, Authentik, Zitadel, and others).
Google, GitHub, and Okta one-click presets move to Skipper. LDAP / Active
Directory and scoped RBAC are Admiral-only.

Backend enforces the split via a new requireTierForSsoProvider helper in
middleware/tierGates.ts, applied after requireAdmin in all four ssoConfig
mutation handlers. GET /sso/config (list) stays ungated so downgraded
admins can still see previously-configured providers. Invalid provider ids
now 400 before the tier check to avoid leaking tier information.

Frontend adds a compact mode to PaidGate and AdmiralGate for inline
list-item locks, and SSOSection reorders the provider cards as
Custom OIDC > Google > GitHub > Okta > LDAP to reinforce the
free-to-paid progression.

Stale 'SSO is Admiral' copy in AdmiralGate, PaidGate, and the Admiral
upgrade card on the License settings page has been replaced to reflect the
new split. User-facing licensing, SSO, overview, quickstart, and security
docs have been updated with the per-tier provider matrix.
2026-04-24 15:48:03 -04:00
Anso 43a595905b fix(backend): restore remote proxy mount order before local routers (#747)
The index.ts refactor inverted the proxy mount order. The pre-refactor
monolith mounted `app.use('/api/', remoteNodeProxy)` before any inline
route, so remote-nodeId requests short-circuited into the proxy. After
the refactor the proxy was registered after every per-group router, so
Express matched local routers first and remote-nodeId requests were
silently handled with the control instance's local state (e.g.
GET /api/stacks with x-node-id=<remote> returned local stacks rather
than the remote's).

Fix moves createRemoteProxyMiddleware() between enforceApiTokenScope
and the first per-group router, matching middleware-order.md step 13
and restoring pre-refactor behavior. PROXY_EXEMPT_PREFIXES continues to
cover gateway-level paths (auth, nodes, license, fleet, webhooks, meta)
that must stay local even when x-node-id targets a remote.

Add four regression guards that would have caught this:

- json-parser-bypass.test.ts: asserts conditionalJsonParser leaves the
  request stream intact on proxy-eligible paths so http-proxy can pipe
  the raw body to the upstream; spins up a local echo server and
  verifies the bytes arrive.
- proxy-mount-order.test.ts: asserts a remote-nodeId GET short-circuits
  into the proxy (502 from unreachable upstream) instead of matching a
  local router (200 from local state).
- upgrade-order.test.ts: pins WebSocket dispatch order by observing
  handler-specific side effects for notifications, remote forwarder,
  logs, and pilot tunnel.
- remote-console-session.test.ts: asserts the HTTP console-token route
  mints a JWT with the same claim shape as the shared mintConsoleSession
  helper, so gateway and WS forwarder tokens remain interchangeable.

Full suite: 73 files, 1,358 tests, all passing.
2026-04-24 10:20:08 -04:00