1324 Commits

Author SHA1 Message Date
sencho-quartermaster[bot] 249cfdcc87 chore(main): release 0.86.6 (#1180)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com>
v0.86.6
2026-05-23 16:17:15 -04:00
Anso f03c9dc7b6 fix(webhooks): address Codex review of PR #1177 (#1181)
* fix(webhooks): close HMAC timing oracle on trigger reject paths

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

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

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

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

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

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

Two new tests pin both fixes.

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

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

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

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

* refactor(sidebar): apply Ops Pulse audit fixes

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

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

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

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

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

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

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

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

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

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

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

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

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

POST /api/webhooks/:id/trigger:
  * requestBody required: true (body is now mandatory; the H3
    fail-closed branch rejects a missing rawBody).
  * action override: enum restricted to the allowlist.
  * 401 and 403 responses removed.
  * 404 response: description rewritten to reflect uniform-404
    behaviour; the body is { error: "Webhook not found or signature
    invalid" } for every unauthenticated reject.
  * 400 response added for an authenticated request whose action
    override is not in the allowlist.
2026-05-23 15:42:49 -04:00
Anso a9282671d5 fix(webhooks): hide write affordances from non-admin paid users (#1176)
* fix(webhooks): hide write affordances from non-admin paid users

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

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

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

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

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

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

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

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

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

* fix(ci): restore backend and frontend checks

* fix(e2e): remove create button timing race

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

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

Independent audit on the previous commit surfaced two issues.

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

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

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

This fixes the Node 26 frontend CI failures where tests accessed localStorage during setup.
2026-05-23 06:05:54 -04:00
sencho-quartermaster[bot] 579e672e24 chore(main): release 0.86.5 (#1167)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com>
v0.86.5
2026-05-23 03:08:53 -04:00
dependabot[bot] 741ed61c2f chore(deps): bump the all-actions group across 2 directories with 7 updates (#1174)
Bumps the all-actions group with 6 updates in the / directory:

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

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


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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 03:07:20 -04:00
dependabot[bot] f6a2c7baae chore(deps): bump the all-npm-frontend group in /frontend with 4 updates (#1172)
Bumps the all-npm-frontend group in /frontend with 4 updates: [date-fns](https://github.com/date-fns/date-fns), [geist](https://github.com/vercel/geist-font/tree/HEAD/packages/next), [motion](https://github.com/motiondivision/motion) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 03:06:10 -04:00
dependabot[bot] ec87a63f0a chore(deps): bump node from e71ac5e to 7c6af15 (#1171)
Bumps node from `e71ac5e` to `7c6af15`.

---
updated-dependencies:
- dependency-name: node
  dependency-version: 26-alpine
  dependency-type: direct:production
...

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

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

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

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

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

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

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

Two issues from independent review.

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

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

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

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

The rewrite turns the test inside-out:

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

No product code change. The synchronous creatingEmptyRef guard already
landed in 61e79861.
2026-05-23 03:04:53 -04:00
Anso 43fa8eeada ci: remove sync-docs job and its dead push-to-main trigger (#1170)
The sync-docs job mirrored /docs into the sencho-docs repo on every push
to main but was always skipped on PR events, surfacing as a permanent
'skipped' status check that did not reflect any real work. Removed
entirely; docs publishing will be wired up separately.

With no remaining job consuming the push trigger (the four CI jobs all
gate on github.event_name == 'pull_request'), the workflow now runs only
on pull_request events. This eliminates the empty workflow runs that
fired on every merge to main.
2026-05-23 02:37:45 -04:00
Anso efcc06d50b ci: harden CI and supply-chain pipeline (#1169)
* ci: harden CI and supply-chain pipeline

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

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

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

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

Configuring environmentOptions.jsdom.url with a real same-origin URL is
the documented Vitest 4.x workaround and is a config-only change. All 27
test files (241 tests) pass locally with the fix applied.
2026-05-23 02:37:29 -04:00
Anso 0947da13ae fix(security): collapse repeated trivy-missing pre-deploy notifications (#1166)
Scan policies on a node without Trivy installed previously fired one
"Pre-deploy scan skipped" warning per deploy, flooding the notification
feed during CI loops. Add a 60-minute per-(node, stack) cooldown so an
operator sees one actionable warning, not one per deploy. The boot log
line and the one-click managed install in Settings > Security are
unchanged; this only reshapes the per-deploy fanout.

Also tighten the vulnerability-scanning entry in /docs/features/overview
to point first-touch users at the one-click install on first use.
2026-05-23 01:18:30 -04:00
sencho-quartermaster[bot] e9af5c0d5d chore(main): release 0.86.4 (#1165)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com>
v0.86.4
2026-05-23 00:17:49 -04:00
Anso a51547a158 fix(monitor): decouple janitor disk-usage check from 30s cycle (F-6) (#1164)
* fix(monitor): decouple janitor disk-usage check from 30s cycle (F-6)

`docker system df` (called by the MonitorService janitor check) can take
30+ seconds on Docker Desktop with many volumes. Running it on the 30s
evaluate cycle compounded with the per-container stats fan-out and pushed
the cycle to 140s+, blocking subsequent monitoring work.

This change:

- Moves the janitor disk-usage check into its own 15-minute cycle with
  a tight 8s timeout. A circuit breaker opens after 3 consecutive
  timeouts (60-minute cooldown) so a sick daemon stops pinning Dockerode
  sockets every tick. The first janitor tick is deferred 45 seconds past
  boot to avoid head-of-line collision with the initial monitor cycle's
  stats fan-out.
- Adds a paired 8s `withTimeout` wrap to the admin prune-estimate routes
  (`/api/system/prune/estimate` and the dry-run path of
  `/api/system/prune/system`) so a slow df does not hang the admin tab.
  Both routes respond 503 with code `docker_df_slow` on timeout.
- Factors `withTimeout` and `TimeoutError` into `utils/withTimeout.ts`
  so the route layer does not have to import from a service module.
- Adds 10 unit tests covering the decoupling guardrail, breaker
  open/close, cooldown, threshold gate, the 100 MB reclaimable floor,
  re-entrancy, recovery logging, non-timeout error handling, and the
  full timer-cleanup contract of `stop()`.
- Adds 4 integration tests for the prune routes covering the 503
  timeout response, the success path, and the non-timeout 5xx path.

* fix(fleet,monitor): extend F-6 timeout to fleet prune routes; close breaker-recovery log gap

Codex audit findings on PR #1164:

Major. The fleet routes that fan out prune-estimate work on local nodes
(`POST /api/fleet/labels/fleet-prune` dry-run path and
`POST /api/fleet/prune/estimate`) called `estimateSystemReclaim` without
a timeout, so a slow local Docker daemon could still hang the fleet
admin tab even though the system-maintenance routes were already
bounded. Wrap both call sites with the shared `withTimeout(..., 8s)`
and surface a "Docker daemon is busy" message via the per-target and
per-node error channels the routes already used for other failures.
The destructive (non-dry-run) prune path stays unwrapped because it
calls `pruneSystem` / `pruneManagedOnly`, not `df`.

Minor. The janitor circuit breaker zeroed `janitorConsecutiveTimeouts`
when it opened, so a successful call after a full breaker-open cooldown
slipped past the `if (counter > 0)` recovery-log branch and never
emitted `[Monitor] Janitor disk-usage check recovered`. The operator
observability signal was missing exactly when it mattered most.
Extend the predicate to also trip on `janitorBreakerUntil > 0` (which
stays set to its past timestamp after cooldown until the next success
clears it), so recovery logs symmetrically for both partial-failure
and post-breaker recovery paths. Added a dedicated test.

Three new integration tests cover the fleet routes (timeout, success,
and the estimate endpoint's per-node unreachable shape).
2026-05-23 00:04:05 -04:00
sencho-quartermaster[bot] da3e7adc30 chore(main): release 0.86.3 (#1163)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com>
v0.86.3
2026-05-22 21:11:38 -04:00
Anso 64bf6344a3 fix(mesh): bias Sencho static IP via IPAM IPRange (F-13) (#1162)
* fix(mesh): reserve Sencho static IP via IPAM auxiliary address (F-13)

Sencho pins itself to <network>+2 on sencho_mesh, but the IPAM block only
declared Subnet, so Docker freely handed that address to any meshed
workload that restarted while Sencho was offline. A real-world hit on
arrapps-prod during a Sencho upgrade had tautulli grab 172.30.0.2, which
blocked the new Sencho container's mesh attach with "Address already in
use" and left compose in a half-state needing manual disconnect/recreate.

The fix reserves <network>+2 via AuxiliaryAddresses on the IPAM Config
when creating sencho_mesh. Aux-listed addresses are removed from the
auto-allocatable pool, so Docker refuses to hand the IP to any container
that does not explicitly request it. Sencho's own attach via
connectContainerToNetwork({ ipv4Address }) is unaffected because
explicit pins still bind aux-reserved addresses. Workload containers
without a pinned IP get .3 and up.

Wire format verified against the Docker Engine REST v1.33 OpenAPI spec:
the JSON key on POST /networks/create (and on the inspect response) is
AuxiliaryAddresses inside each IPAM.Config item, value { sencho: <ip> }.

Adopt-existing path: when Sencho boots against a sencho_mesh that
pre-dates this reservation, the data plane still comes up but a one-time
warn fires (mesh.enable activity at level: 'warn' plus a [Mesh] console
line so docker logs surfaces it). The advisory explains the squat risk
and gives the recreate recipe.

Tests: 5 cases added to mesh-setup-error-classification.test.ts covering
the explicit-env create payload, the candidate-iteration winner payload,
the adopt-legacy warn (env-unset), the adopt-already-reserved silent
path, and the TOCTOU 409 race-winner adopt-legacy warn.

Docs: one sentence added to docs/features/sencho-mesh.mdx under
"Customising the mesh subnet" describing the reservation positively.

No tier/role/capability/flag gates touched (no frontend changes).

* fix(mesh): use IPRange upper-half bias instead of aux-address reservation

The initial F-13 fix used IPAM AuxiliaryAddresses to reserve <network>+2
on sencho_mesh. Empirical probe against Docker 29.4.3 confirmed this
also blocks explicit pins via EndpointConfig.IPAMConfig.IPv4Address:
libnetwork's RequestAddress() rejects a preferred-address request when
the bit is already set by the aux reservation. Result: the freshly-
reserved network refuses Sencho's own ensureSelfAttached, and the data
plane never comes up.

The pivot uses IPRange instead. IPRange constrains Docker's auto-
allocation to the configured CIDR; preferred-address requests via
RequestAddress(prefAddress) skip the IPRange check and only consult
the subnet-wide bitmap. So setting IPRange to the upper half of the
subnet (e.g. 172.30.0.128/25 for 172.30.0.0/24) biases workloads
without an explicit IP to <network>+128 and up, while Sencho's
explicit pin to <network>+2 still succeeds.

Verified on Docker 29.4.3 with the same workstation that produced the
original audit:
  - `docker run --rm --network N --ip 10.99.99.2` against a network
    with `--aux-address sencho=10.99.99.2` → "Address already in use"
    (rejects explicit pin).
  - Same `--ip` against a network with `--ip-range 10.99.99.128/25` →
    succeeds (10.99.99.2 is outside the range but inside the subnet).
  - Auto-allocated workload on the IPRange network lands at .129.

Adopt-existing legacy detection now compares IPRange instead of
AuxiliaryAddresses. inspectExistingMeshSubnet returns { subnet,
ipRange } and the warn fires when ipRange differs from the expected
upper-half CIDR. Same once-per-process semantics as before.

createMeshNetwork now derives the IPRange via a new
getMeshIpRangeFromSubnet helper. The five regression tests assert
IPRange = <network>+128/<prefix+1> in the create payload and the
expected/actual IPRange in the legacy-warn details.
2026-05-22 21:08:56 -04:00
dependabot[bot] b096e03675 chore(deps): bump qs from 6.15.0 to 6.15.2 in /backend (#1161)
Bumps [qs](https://github.com/ljharb/qs) from 6.15.0 to 6.15.2.
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.15.0...v6.15.2)

---
updated-dependencies:
- dependency-name: qs
  dependency-version: 6.15.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-22 19:18:34 -04:00
sencho-quartermaster[bot] 0fdcec41c7 chore(main): release 0.86.2 (#1160)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com>
v0.86.2
2026-05-22 16:32:20 -04:00
Anso 474290081d fix(mesh): log boot state to console so docker logs surfaces mesh status (#1159)
MeshService records its boot summary and setup failures only through
logActivity (in-memory ring buffer + WS listeners), which the Routing
tab consumes. The docker-logs surface was silent for the mesh
subsystem, so operators running Sencho-in-Docker had no boot-time
visibility into whether the data plane came up cleanly.

Mirror the existing logActivity entries to console without replacing
them:

- MeshService.start() success summary: console.log /
  console.warn / console.error gated on the summary level. Format:
  [Mesh] data plane ok, self attached at <ip>, subnet <X>
  [Mesh] data plane unavailable (<reason>: <message>)
- recordSetupFailure: console.warn for the expected dev-mode
  not_in_docker case, console.error for real failures. Format:
  [Mesh] data plane unavailable (<reason>, subnet <X>): <sanitized>

The activity entries that already drive the Routing tab banner stay
intact; the console lines are purely additive for the docker logs
workflow. Two new unit tests assert the failure and not_in_docker
console mirrors fire with the [Mesh] prefix.

Fixes F-5.
2026-05-22 16:30:10 -04:00
sencho-quartermaster[bot] c87dc7e747 chore(main): release 0.86.1 (#1157)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com>
v0.86.1
2026-05-22 13:43:39 -04:00
Anso e4fe4cfced fix(mesh): address Codex audit findings on F-1 PR (#1158)
- docs(sencho-mesh): split subnet_overlap troubleshooting into env-set
  vs env-unset paths; rewrite the "Customising the mesh subnet" intro
  to describe the candidate list and the adopt-existing behavior.
- backend(MeshService): preserve idempotent 409 handling in the
  explicit-env path. On createNetwork 409 (TOCTOU race against another
  process), re-inspect and treat the race-winner as success when its
  subnet matches the operator's request; subnet_mismatch otherwise.
- frontend(MeshDataPlaneBanner): trim the card variant to a true
  one-line strip (headline only, truncate min-w-0). Full recovery
  hint stays on the Routing tab variant and in docs.
- tests(mesh): add five cases covering the previously untested
  branches — candidate-loop non-overlap bail, adopt-existing with
  unparseable subnet, explicit-env generic createNetwork failure,
  TOCTOU 409 race-winner match, TOCTOU 409 race-winner mismatch.

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

MeshService.setupMeshNetwork now resolves the subnet via three paths:

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

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

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

Fixes F-1 in the pre-1.0 audit. Closes the silent-failure mode that
left the mesh down on the most common homelab Docker layout.
2026-05-22 13:20:27 -04:00
sencho-quartermaster[bot] 606bdb6b67 chore(main): release 0.86.0 (#1139)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com>
v0.86.0
2026-05-22 02:41:22 -04:00
Anso cd1cde2fd4 fix(resources): subtract shared layers when accounting managed prune bytes (#1155)
* fix(resources): subtract shared layers when accounting managed prune bytes

Both `pruneManagedOnly` and `estimateManagedReclaim` walked the
Sencho-managed prunable image set and summed `img.Size` per image. That
counts shared base layers once per image, so a 1 GB base layer shared
across N managed images was reported as N GB freed — the same shape of
inflation we just fixed for the system-scope banner.

Introduce `getImageSharedSizeMap()` which reads `df.Images[].SharedSize`
once and lets both code paths subtract `SharedSize` per image when
totalling: `+= max(0, Size - shared)`. If `df` fails, the helper returns
an empty map and the accounting degrades to the prior sum-of-Size
behavior rather than failing the prune.

Verified against a live daemon: `/api/system/prune/estimate` with
`scope: managed, target: images` now returns the layer-aware number;
the older per-image-Size sum was roughly 1.8× larger on the same set.

* fix(resources): use df-delta for destructive managed prune; label estimate as lower bound

The first attempt at this PR subtracted SharedSize per prunable image on
both paths. That formula undercounts when prunable images share a layer
exclusively with each other: Docker frees the layer once, but the
per-image subtraction removes it from every referrer. The reported total
is then strictly less than the truth.

Split the two paths:

- pruneManagedOnly (destructive) now snapshots `docker df` before and
  after the parallel removes and reports `max(0, before.LayersSize -
  after.LayersSize)`. That is the honest measurement of bytes freed.
  Concurrent pulls during the prune can grow the after value; the clamp
  treats that as 0 reclaimed for the affected delta rather than
  attributing the new bytes to us.

- estimateManagedReclaim keeps the per-image Σ(Size - SharedSize)
  formula but the JSDoc now calls it a "conservative lower bound" and
  documents the under-report mechanism. There is no cheap way to
  exactly price an arbitrary prune subset without per-layer enumeration.

Fallback chain when df fails on the destructive path:
- before-snapshot succeeded, after failed → per-image lower bound from
  before-snapshot (safe; SharedSize was known at start).
- before-snapshot failed → report 0 with a warn log (after-only would
  build a SharedSize map missing the just-pruned images, which would
  over-report by treating them as having no sharing).

Replaces the prior `getImageSharedSizeMap()` helper with two pieces:
`safeDfSnapshot()` (I/O) and a private static `mapSharedSizesFromDf()`
(pure parse), reused by both code paths.

New invariant test asserts `prune.reclaimedBytes >= estimate.reclaimableBytes`
on the same inputs so future changes to either formula cannot flip the
direction.

Addresses Codex audit blocker on PR #1155.
2026-05-22 02:40:22 -04:00
Anso a1caf6b0dd fix(resources): use daemon-reported reclaimable image bytes (#1154)
* fix(resources): use daemon-reported reclaimable image bytes

The Reclaim banner summed per-image `VirtualSize` (or `Size`) across
every image with no running container. That counts shared base layers
once per image, so an unused base layer of 1 GB shared across ten builds
showed up as 10 GB of "prunable" space. The actual prune frees the
layer once and reports a much smaller `SpaceReclaimed`, leaving the
banner and the post-prune toast badly out of step.

Prefer Docker's own `ImageUsage.Reclaimable` (API v1.44+), which is the
exact value `docker system df` displays. Older daemons fall back to the
Docker CLI's internal formula: `LayersSize - sum(Size - SharedSize)`
for in-use images, clamped to 0, skipping any image Docker flags with
the -1 unknown-size sentinel.

Verified against a live daemon: the banner now matches
`docker system df`'s IMAGES RECLAIMABLE byte-for-byte.

* fix(resources): treat SharedSize=-1 as 0 in fallback, don't drop in-use bytes

The fallback formula is `LayersSize - used`. The previous version skipped
any active image whose VirtualSize or SharedSize was -1 (Docker's
"unknown" sentinel). Skipping leaves the image's bytes out of `used`,
which reads back as reclaimable -- the exact inflation the PR set out
to fix, just on older daemons.

Treat SharedSize=-1 (or absent) as 0 so the image's full size counts as
in-use, and only skip when no usable size is available at all
(VirtualSize and Size both unknown). Under-reporting reclaimable is the
safe direction; over-reporting was the original bug.

Add a test for the SharedSize=-1 case with Size known, and rename the
existing test so it reflects what is actually being asserted now.

Addresses Codex audit blocker on PR #1154.
2026-05-22 02:40:06 -04:00
Anso 519a59ed2e feat(fleet): open Fleet Actions tab to Community (admin-only) (#1153)
* feat(fleet): open Fleet Actions tab to Community (admin-only)

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

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

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

- stack-labels.mdx: fix the page intro that still framed fleet label
  actions as "Operators on a Skipper or Admiral license". The cards are
  now Community + admin, so the intro reads "Admins also get a pair of
  fleet-wide actions".
- Collapse redundant role-rule statements on the two affected pages.
  fleet-actions.mdx now states the admin gate once in the lead-in Note
  and again only in the troubleshooting accordion (the Prerequisites
  row was duplicative). stack-labels.mdx trims the "Limits and rules"
  bullet to the value-add half (label authoring is open to every role)
  and drops the Fleet Actions repetition.
- Strip now-no-op mockTier('paid') calls from non-tier tests across the
  three fleet test files, plus the test-wide default in the
  fleet-action-card-endpoints beforeEach. Those mocks were misleading
  after the routes stopped consulting tier; if a future change re-adds
  requirePaid the tests will fail loudly instead of silently passing.
2026-05-22 01:27:27 -04:00
Anso 2f2401df68 fix(fleet): route remaining fleet dispatches through getProxyTarget for pilot-agent nodes (#1152)
* fix(fleet): route remaining fleet dispatches through getProxyTarget for pilot-agent nodes

PR #1123 migrated POST /api/fleet/nodes/:id/update to use
NodeRegistry.getProxyTarget so pilot-agent rows (no api_url / api_token)
participate in remote update via the tunnel loopback. The same bug shape
lived on at ten sibling fleet-dispatch sites: each read node.api_url and
node.api_token directly, returning "Remote node not configured." against
pilots, or silently filtered pilot rows out of a fan-out loop.

Migrate every remaining fleet-wide remote dispatch to the same pattern:

- routes/fleet.ts: fleet-stop, fleet-prune, prune/estimate, snapshot
  restore (4 sites) -> getProxyTarget + mode-aware error copy +
  conditional Authorization header
- routes/imageUpdates.ts: fleet status + fleet refresh (2 sites) -> swap
  n.api_url filter for getProxyTarget != null and use target.apiUrl, so
  pilot rows appear in the aggregated image-updates view instead of
  being silently excluded
- utils/snapshot-capture.ts: captureRemoteNodeFiles (1 site) -> same
  pattern; CaptureNode interface gains required mode field so the
  thrown error message picks the pilot-tunnel copy automatically
- services/SecretsService.ts: resolveEnvFileRemote, readEnvRemote,
  writeEnvRemote (3 sites) -> same pattern; thrown errors now use the
  shared formatNoTargetError helper instead of leaking api_url/api_token
  field names

Extract the previously-private noTargetMessage helper from fleet.ts
into utils/remoteTarget.ts as formatNoTargetError so SecretsService,
snapshot-capture, and the fleet routes share one copy of the
mode-aware error string.

Add 10 regression tests in fleet-pilot-dispatch-parity.test.ts covering
each migrated route + the snapshot-capture utility: dispatch through
the loopback target with no Authorization header for pilots, and a
mode-aware error when the tunnel is disconnected.

FleetSyncService (4 additional sites) carries an api_url-anchored
targetIdentity in the wire protocol; pilot support there needs a
protocol-level identity decision and stays as a separate follow-up.

* fix(fleet): throw tunnel-disconnected error from resolveEnvFileRemote

Codex audit flagged that resolveEnvFileRemote returned null when
getProxyTarget was null. That predates the parity migration but the
migration was the right place to fix it: the null flowed through
readExistingEnv into previewPushDiff / executePush as "env file not
found", which is wrong (the env exists, the node is unreachable).

Throwing formatNoTargetError(node) here lets the existing catch arms
in previewPushDiff (lines 450-453) surface reachable=false with the
tunnel-disconnected message on the right axis, and executePush picks
up the same shape via its outer catch.

Also drop overstated coverage claims from the parity test header
(snapshot restore + SecretsService were never actually exercised in
this file, only structurally identical via tsc), and fix two describe
labels that read /api/labels/* instead of the mounted /api/fleet/labels/*.
2026-05-22 00:30:51 -04:00
Anso 60f893a81f feat(fleet): move bulk Remote OTA updates to Community tier (#1151)
Drops `requirePaid` from `POST /api/fleet/update-all` so Community admins
can dispatch bulk node updates. Per-node OTA was already Community-
reachable (admin-only); this completes the move so the full Remote OTA
surface ships at Community.

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

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

Test coverage:
- fleet.test.ts: tier-gating spec flipped to assert Community access
- fleet-pilot-update.test.ts: bulk-OTA dispatch suite now spies tier
  to Community so it doubles as a regression guard
2026-05-21 23:54:45 -04:00
Anso 8a3889dc67 feat(security): move managed Trivy auto-update to Skipper tier (#1150)
* feat(security): move managed Trivy auto-update to Skipper tier

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

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

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

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

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

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

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

Add route tests covering paid admin allowed, paid viewer rejected,
community admin rejected, and unauthenticated rejected.
2026-05-21 23:54:01 -04:00
Anso b740dd1078 feat(resources): protect Sencho's own image, network, volumes from deletion (#1149)
* feat(resources): protect Sencho's own image, network, volumes from deletion

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

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

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

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

Addresses independent-review findings on PR #1149:

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

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

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

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

Adds a not_affected entry to security/vex/sencho.openvex.json with
justification vulnerable_code_not_in_execute_path, bumps version 5 to 6,
and updates last_updated to 2026-05-22 per Directive 23.
2026-05-21 23:10:13 -04:00
Anso e57799d4df docs: expand Features subgroups by default (#1148)
Mintlify nested groups collapse unless `expanded: true` is set on each
group object. Adding it to all six Features subgroups so users land on
a fully scannable sidebar instead of six folded headers.
2026-05-21 22:02:07 -04:00
Anso 5f7a887ed6 docs: reorganize navigation for feature discoverability (#1147)
Restructures the Documentation tab so each group answers one operator
question.

- Split the 12-page "Stacks & Deployments" into Stacks (per-stack work)
  and Deployment (the act of deploying); promote Resources Hub to a
  standalone item.
- Dissolve the 2-page "Platform" junk drawer: Sidebar moves to Stacks,
  Host Console moves to Fleet.
- Rename "Fleet & Multi-Node" to "Fleet"; absorb Node Compatibility
  from Reference. Move Scheduled Operations from Fleet to Automation
  (now a 4-page group covering Scheduled Ops, Auto-Update, Auto-Heal,
  Webhooks).
- Clean up the Reference tab: drop misplaced node-compatibility, move
  root-level security.mdx into reference/, delete the orphan
  reference/verifying-images.mdx after porting its Available Tags
  table into operations/verifying-images.mdx.
- Reorder top-level groups: Operations moves above Reference.
- Rename two misleading page titles: "Deploy Progress Modal" becomes
  "Deploy Progress" (drops the UI implementation leak); "Auto-Update
  Readiness" becomes "Auto-Update Policies" (matches filename and
  sibling "Auto-Heal Policies").

Verified: docs.json parses as valid JSON, 59 disk .mdx files match
59 nav entries with zero orphans and zero broken refs.
2026-05-21 21:25:23 -04:00
Anso be22e3ded1 docs(api-tokens): deep rewrite for v1, expand to fleet automation guide (#1140)
Rewrite docs/features/api-tokens.mdx (115 → 442 lines) as a full
product + technical guide: mental model, scope ladder, prerequisites,
step-by-step usage with HTTP/WS/multi-node examples, complete universal-
restriction table, cross-node proxy behaviour, lifecycle, rate-limit
ceiling, security model, limitations, three example workflows,
troubleshooting accordion, FAQ accordion.

Replace the single populated-list screenshot with five captured against
production: empty state, create form, reveal banner (token value
redacted), populated list with all three scope variants, revoke modal.

Sibling-doc edits keep tier statements coherent now that API tokens are
available on every tier:
- docs/api-reference/overview.mdx: drop the Admiral-only Note callout,
  drop API Tokens from the Admiral-gated license-tier table, correct
  the rate-limit table to say tokens are keyed per-credential
- docs/features/overview.mdx: drop the "Admiral only." sentence
- docs/security.mdx: drop "Admiral tier.", move API tokens row to every
  tier in the security matrix, repoint image to the new populated shot
2026-05-21 21:20:59 -04:00
Anso dc8a368785 fix(frontend): wire favicon to existing logo assets (#1146)
The previous `<link rel="icon">` pointed at `/sencho-logo.png`, which is
not present in `frontend/public/`, so every page load 404'd the favicon
and browser tabs fell back to the generic globe glyph.

Point the icon at the two PNGs that actually ship in `frontend/public/`
(`sencho-logo-dark.png` and `sencho-logo-light.png`) and let the browser
pick the right one via `prefers-color-scheme`. Add an `apple-touch-icon`
so iOS home-screen pinning gets a real asset instead of a screenshot.
2026-05-21 21:11:56 -04:00
Anso 66b84932e0 feat(notifications): move Notification Routing to Skipper tier (#1145)
* feat(notifications): move Notification Routing to Skipper tier

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

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

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

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

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

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

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

Tests: the Community-negative cases on POST/PUT/DELETE/POST :id/test
could not distinguish requirePaid from a stray requireAdmiral, because
Community fails on the tier check before variant is read. Adding
Skipper-positive coverage per endpoint locks the gate identity in.
Replace the leaky mockReturnValueOnce with a per-test mockReturnValue
plus an afterEach restore so spies cannot bleed across tests.
2026-05-21 20:57:34 -04:00
Anso 535023b350 feat(files): open stack file explorer to every tier (#1144)
* feat(files): open stack file explorer to every tier

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

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

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

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

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

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

Writes are now gated on the `stack:edit` role, not on the license tier.
Repurpose the community describe to assert that a Community admin
under a mocked community license still sees the Upload control and an
editable Save button. Drop the obsolete tier-skip in the admin describe
so upload, edit, delete, and download exercise on every tier. Update
stale comments to reference the role gate.
2026-05-21 19:35:31 -04:00
Anso 380ed6fd50 feat(cloud-backup): make Custom S3-compatible target available on every tier (#1143)
* feat(cloud-backup): make Custom S3-compatible target available on every tier

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

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

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

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

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

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

Also tidies the Fleet Backups doc, which still claimed the cloud-upload
icon was Admiral only; the icon now renders whenever a Cloud Backup
target is configured.
2026-05-21 18:11:16 -04:00
Anso c491d309c1 feat(schedules): make every scheduled action available on Skipper (#1141)
Collapse the Admiral carveout that restricted restart, prune, auto_backup,
auto_stop, auto_down, and auto_start schedules to the Admiral variant.
Scheduled Operations stays at Skipper+ (paid). The action picker now lists
every supported operation for any paid admin, and the scheduler runner
executes every action on either variant.
2026-05-21 16:42:41 -04:00
Anso 8d1304b0df fix(hub-only-guard): reject hub-only collection paths without trailing slash (#1142)
HUB_ONLY_PREFIXES entries are stored with a trailing slash, but
isHubOnlyPath() used a bare startsWith(). The collection paths
/api/scheduled-tasks, /api/audit-log, and /api/notification-routes
(no trailing slash) silently fell through the guard and could be
forwarded by the remote proxy, bypassing the hub-only boundary on
three path families.

The matcher now accepts the exact prefix (slash stripped) in addition
to the slash-suffixed prefix. Adds three regression tests covering
the bare-collection-path 403 for each hub-only family. Confirmed the
new tests fail without the matcher change and pass with it.
2026-05-21 16:42:18 -04:00
Anso 2563e30424 fix(fleet): equalize action card heights, swap order, replace native autocomplete (#1138)
Three visual adjustments to the Fleet Actions tab:

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

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

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

Also: whitespace-nowrap on the primary button so "Stop fleet" /
"Prune fleet" do not wrap in tight viewports.
2026-05-21 12:51:54 -04:00
sencho-quartermaster[bot] 0a3e76c0bb chore(main): release 0.85.0 (#1135)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com>
v0.85.0
2026-05-21 11:48:29 -04:00
Anso 6f301e005a feat(fleet): redesign Fleet Action cards to the System Sheet recipe (#1137)
Lift <FleetActionCard> primitive from audit §20 / DESIGN §9.12. Migrate
the three v1 cards (stop-by-label, bulk-label-assign, prune-fleet-wide)
to consume it: cyan rail on every card, action class as a chip, blast
radius as a live readout in the toolbar, preview section replaces the
warning banner, footer carries reversibility plus freshness. Drop the
per-card tone rail, the icon prop, and cards/tone.ts.

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

Extend <SheetSection> with optional meta. Add --action-transformative
semantic token.
2026-05-21 11:45:52 -04:00
Anso d0e140444a feat(api-tokens): make API tokens available on every tier (#1136)
API tokens are credential management, not a tier-gated capability. Remove
the Admiral gate from the POST/GET/DELETE handlers, drop the AdmiralGate
wrapper from the settings UI, set the registry entry's tier to null so the
tab renders on every tier, and update the docs Note to state availability
plainly.

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

The Vitest suite now runs at Community tier to prove every code path works
without a paid license. A new "API token tier accessibility" describe block
mints all three scopes via POST /api/api-tokens to lock the behavior.
2026-05-21 11:45:37 -04:00
Anso 9090de3a38 docs(licensing): refresh tier prices and rename Lifetime to Founder Lifetime (#1134)
Update the docs pricing table and surrounding prose to match the website:

- Skipper: $5.75/mo billed yearly ($69/yr), $9.99/mo, $149 Founder Lifetime
- Admiral: $20.75/mo billed yearly ($249/yr), $39.99/mo, $499 Founder Lifetime
- Enterprise: custom pricing (was a fixed annual minimum)
- Rename the lifetime cycle to "Founder Lifetime" in the table column,
  the EA blurb, and the trial-section caveat. The internal license-duration
  type ("DURATION: lifetime" masthead pill, "Lifetime licenses" subsection)
  is unchanged because it describes app runtime behavior, not the SKU.
2026-05-21 10:18:24 -04:00
Anso cdb9cd414e feat(routing): redesign node cards to the System Sheet recipe (#1133)
Lift <RoutingNodeCard> primitive from audit §21 / DESIGN §9.13. Migrate
the fleet adapter to consume it: collapse five inline state pills into
a single nodeState prop, replace the k/v stat list with a mono meta
line, drop the per-row HEALTHY chip in favor of a state dot, and add a
real empty-state block with a primary CTA for every state. Compact
density body swap stays inside the component, no -compact fork.
2026-05-21 10:18:03 -04:00
sencho-quartermaster[bot] 620e28f352 chore(main): release 0.84.3 (#1132)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com>
v0.84.3
2026-05-21 01:04:21 -04:00