Commit Graph

4 Commits

Author SHA1 Message Date
Anso 0683aa9395 fix(settings): reflect role, tier, and node scope in settings panels (#1300)
* fix(settings): reflect role, tier, and node scope in settings panels

Three gate fixes so the Settings panels match what the backend enforces.

Non-admin roles saw editable fields and a Save button on System Limits,
Developer, and App Store, but writes require admin, so Save always failed.
These panels now render read-only for non-admins (controls disabled, Save
hidden) while still showing the values.

The Developer panel is node-scoped but read and wrote the controlling
instance regardless of the selected node, so a remote node's debug mode and
retention windows could not be changed from the UI. It now targets the
active node like System Limits.

The settings shell derived the Admiral entitlement locally; it now consumes
the backend-provided value the API authorizes against, and that value is
corrected to require an active paid tier so an expired Admiral license no
longer reports as Admiral.

* fix(settings): gate audit_retention_days writes behind Admiral

audit_retention_days configures the Admiral-only audit log (the audit-log
routes require Admiral, and the Developer settings UI only shows the field to
Admiral operators), but the settings POST/PATCH handlers only required an admin
role. A non-Admiral admin (for example Skipper, or an expired-Admiral admin
whose tier dropped to community) could still set it through the API.

Gate writes to that key with requireAdmiral on both the single-key POST and the
bulk PATCH paths, matching the audit-log routes and the UI. Other keys remain
writable by any admin.

* fix(settings): reject unknown keys on PATCH instead of silently stripping

The bulk settings PATCH validated the body with a Zod object schema that
strips unknown keys by default, so a request carrying a disallowed key (for
example an auth_* secret) returned 200 as a no-op instead of being rejected.
No secret was written, but it diverged from the single-key POST path, which
rejects disallowed keys, and could hide client drift.

PATCH now rejects any key outside the allowlist with a 400 before validation
or write, keeping the bulk path fail-closed and consistent with POST.
2026-06-04 01:49:23 -04:00
Anso 4596a90474 fix(settings): serialize GET /api/settings from an allowlist (#1299)
The settings read returned the entire global_settings map minus a small
denylist of auth keys. Other subsystems persist their config in the same
table, so cloud backup values (endpoint, bucket, access key) were returned
to any authenticated user, including read-only roles, bypassing the
redaction the dedicated cloud-backup endpoint applies.

Project the response from the operational allowlist instead, so only the
keys the settings UI reads are returned and any key written to
global_settings by another subsystem is excluded by default. Remove the
now-unused denylist constant.
2026-06-04 01:49:03 -04:00
Anso aa3d99a594 fix(mesh): re-evaluate data plane every 10s and add opt-in auto-recreate (#1184)
* fix(mesh): re-evaluate data plane every 10s and add opt-in auto-recreate

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

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

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

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

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

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

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

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

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

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

Three findings from the independent review:

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

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

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

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

71/71 backend tests green (revalidate + mesh-setup + settings-routes).
276/276 frontend tests green. tsc clean on backend + frontend.
2026-05-23 18:09:39 -04:00
Anso f6a7898798 refactor(backend): add route tests then extract settings, scheduled-tasks, agents (phase 4b) (#737)
Round B of Phase 4. Writes integration tests for three under-covered
route groups BEFORE extracting them, then does the extraction once the
new tests pass against the monolith. index.ts drops from ~4,206 to
~3,678 lines.

New test coverage (42 new assertions):
- settings-routes.test.ts (14) — auth, admin gating, private-key stripping,
  allowlist, single-key write, bulk PATCH validation + partial update
- scheduled-tasks-routes.test.ts (18) — list/create/get/toggle/delete/runs,
  action+target_type matrix, cron validation, tier gating on non-admin
- agents-routes.test.ts (10) — GET/POST, admin gating, channel type +
  HTTPS URL validation, boolean enabled check, upsert semantics

Each suite was verified against the inline monolith first, then the
route extraction was performed byte-for-byte and all suites re-run to
ensure no regression.

New route files:
- routes/settings.ts — GET/POST/PATCH with PRIVATE_SETTINGS_KEYS strip,
  ALLOWED_SETTING_KEYS allowlist, and SettingsPatchSchema zod bulk schema
- routes/scheduledTasks.ts — 9 endpoints (list, create, get, update,
  delete, toggle, run-now, runs history, runs CSV export). File-local
  helpers parseTaskId, validateActionTarget, validateOptionalFields
  collapse duplication across create+update handlers. Uses shared
  escapeCsvField from utils/csv.ts.
- routes/agents.ts — notification-channel GET/POST. Owns
  NOTIFICATION_CHANNEL_TYPES and validateHttpsUrl locally because the
  notification-routes block still inlines identical copies; the helpers
  will converge once those routes extract in a later slice.
2026-04-23 21:22:39 -04:00