Commit Graph

1031 Commits

Author SHA1 Message Date
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
sencho-quartermaster[bot] b6b154cb99 chore(main): release 0.67.1 (#885)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com>
2026-05-02 16:51:35 -04:00
Anso 1115650e78 chore(ci): drop auto screenshot refresh job, switch to manual capture (#884)
The release-only `update-screenshots` job opened a `chore/refresh-screenshots`
PR and immediately tried to squash-merge it. Branch protection (1 review,
6 status checks) rejected the merge on every release, leaving an open PR
behind. Screenshots will instead be refreshed manually after UI changes.

Removed:
- The `update-screenshots` job from ci.yml (~57 lines).
- The `paths-ignore: docs/images/**` push trigger filter; its sole purpose
  was to break the auto-merge re-trigger cascade. Its absence also fixes a
  latent bug where docs-only pushes to main would have skipped sync-docs.
- Four `head_ref != 'chore/refresh-screenshots'` guards in other jobs.
- The "doc screenshots" mention in the skip-bot-PRs comment.

Reworked the screenshot capture spec to be opt-in:
- playwright.config.ts now defines two projects. The default `chromium`
  project ignores screenshots.spec.ts; a separate `screenshots` project
  matches it and is invoked manually.
- The e2e CI job runs `--project=chromium` so the screenshots project
  cannot accidentally run in CI.
- Updated the spec's module comment with the new manual invocation.

Net: 86 lines removed, 27 added.
2026-05-02 15:57:45 -04:00
Anso 87abfc2ec0 fix(ci): tolerate empty inline blogPosts array in scaffold script (#883)
The release blog scaffold's index.ts regex required a literal newline
before the closing bracket of the blogPosts array. A fresh website
repo bootstraps the array as an empty inline literal (= []), which
broke the script the first time it ran for v0.67.0.

The closing capture now matches whitespace-then-bracket so both empty
inline and multi-line shapes work, and the rebuild always emits a
clean newline-comma-newline-bracket regardless of input shape.

Verified via dry-run against the current website state.
2026-05-02 15:57:07 -04:00
sencho-quartermaster[bot] 577acc056b docs: refresh screenshots (#882)
Co-authored-by: AnsoCode <18150933+AnsoCode@users.noreply.github.com>
2026-05-02 13:56:08 -04:00
sencho-quartermaster[bot] d810a1bacc chore(main): release 0.67.0 (#871)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com>
2026-05-02 13:27:44 -04:00
Anso 638bd808f8 ci(docker): install @studio-saelix/sencho-pro in production builds (#881)
Phase 2b of the open-core hybrid extraction. After Phase 2a (PR #880)
wired the public-side loader to dynamic-import the private package,
this PR makes production Docker builds actually install the package
so the runtime path uses it. Single image; saelix/sencho remains the
only published image (the original ADR's dual-image plan was rejected
because customers buying paid tiers would otherwise need GitHub auth
to pull a second image, breaking the purchase flow).

Dockerfile (prod-deps stage): a new RUN block after npm ci installs
@studio-saelix/sencho-pro using a BuildKit secret-mounted github_token
for npm.pkg.github.com auth. The .npmrc carrying the token is written
and removed inside the same RUN, plus /root/.npm is wiped to scrub
any verbose-log artifacts that npm might otherwise stash. The token
never enters an image layer (BuildKit excludes secret content from
both layer filesystems and cache keys; docker history shows the
literal $(cat /run/secrets/...) command, not the substituted value).

PRO_PACKAGE_VERSION is a build arg pinned by CI to a literal SemVer
(0.1.0 today) so the scan build and the publish build resolve to the
same package version. Default of `latest` keeps local builds
convenient. When the pro package ships a new version, bump the value
in docker-publish.yml in the same PR that ships the matching public
Sencho release; release-please does not coordinate the two cadences.

Empty-secret branch (no github_token provided, e.g. local dev or
fork PRs) skips the install and prints a notice. The resulting image
runs through the loader's in-tree LicenseService fallback, so PR
validation builds and contributor builds work without any GitHub
auth setup.

docker-publish.yml: both build-push-action invocations (the
pre-publish scan and the multi-arch publish) pass the github_token
secret and PRO_PACKAGE_VERSION build arg. The auto-provisioned
GITHUB_TOKEN's packages:read scope is sufficient because the public
Sencho repo and the private package live in the same Studio-Saelix
GitHub org. Moving the package to a different org would silently
break this contract; the Dockerfile comment block records the
invariant.

ci.yml is intentionally not changed. The PR-time Docker validation
job builds without the secret and exercises the loader's in-tree
fallback path, which is correct for fork PRs (no token access) and
useful for catching fallback-path regressions.

Test plan: tsc clean (no TS changes). The dockerfile install path is
exercised by the next release's pre-publish scan + smoke test, both
of which boot the actual image and call /api/health. Failed dynamic
import or constructor throw would block bootstrap before the listener
binds, so the existing smoke test covers the runtime contract.
2026-05-02 13:24:33 -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 4b18109286 refactor(entitlements): migrate type-only consumers to entitlements/types (#879)
Follows the Phase 1 EntitlementProvider abstraction. Two files
imported tier types from services/LicenseService via the back-compat
re-export added in Phase 1; this PR points them at the canonical
location at entitlements/types and drops the re-export block.

Migrated:
  - backend/src/types/express.ts
  - backend/src/routes/dashboard.ts

After this PR, services/LicenseService.ts has no public type re-
exports. The remaining imports of services/LicenseService are:
  - entitlements/loadProvider.ts: runtime import of the
    LicenseService class itself, the intentional Phase 1 binding
    site.
  - __tests__/license-service-id-validation.test.ts: imports
    SENCHO_LS_* catalog constants and resolveSenchoVariantFromMeta;
    these are LemonSqueezy-implementation-specific and stay in
    services/LicenseService until Phase 2 moves the file to
    @studio-saelix/sencho-pro.

Phase 2's deletion of services/LicenseService.ts now requires zero
public-core consumer changes outside the loader and the LS-specific
test file.

Test results: 89/89 backend test files clean, 1657 passing tests, 5
pre-existing skips, plus the same pre-existing database-metrics
stress test flake under parallel load that consistently passes solo.
2026-05-02 05:21:26 -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 72919ccd1b chore(frontend): polish error boundaries and dismissal sync (#877)
Bundles three small follow-ups deferred from the recent lazy-loading
and gate-refactor PRs:

ErrorBoundary visual harmonization. The top-level boundary used a red
banner with custom button styling that no longer matched the glass-
card aesthetic the lock cards and LazyBoundary settled on. Replace
with the same glass-card + AlertTriangle layout. The user already
knows something broke when this fires; a calm card with a clear Try
again CTA is more actionable than the louder treatment, and a
consistent recovery surface across both boundaries means a user never
sees two different "something went wrong" treatments depending on
which boundary catches the error.

Keyboard focus on boundary trip. When either boundary trips, focus
typically falls back to <body> because the throwing subtree
unmounted. Keyboard users would have to tab from the top to reach the
recovery action. Add a ref on the CTA button and a componentDidUpdate
gate that focuses it on the false-to-true hasError transition. The
gate fires once per trip, not on every error-state re-render, so a
user who tabbed elsewhere within the card does not get focus stolen
back. Verified the gate also fires on a re-error after Try again
(setState({hasError:false}) re-renders with prevState.hasError=false,
the next throw flips to true and the transition condition triggers).

Cross-tab dismissal sync in useDismissalState. The hook previously
read localStorage only in the lazy initializer, so dismissing in tab
A did not propagate to tab B until tab B re-mounted. Add a useEffect
that listens for storage events on the configured key. The browser
fires storage events only in OTHER tabs than the one that wrote the
change, so this handles the tab B receives tab A's dismiss case;
same-tab updates flow through setDismissed directly, unchanged.
Malformed event.newValue (NaN, empty string) defaults to dismissed=
false, the conservative outcome.

Adds 4 new vitest cases for the storage-event paths: recent
timestamp, null newValue (restore), unrelated key, and stale
timestamp.
2026-05-02 04:15:49 -04:00
Anso 677f0778e7 refactor(frontend): extract shared parts from PaidGate and AdmiralGate (#876)
PaidGate and AdmiralGate were ~95% identical: same state machine
(unlocked / compact-blurred / dismissed-pill / full-upsell-card), same
24h localStorage-backed dismissal logic, differing only in license
predicate, dismiss-storage key, icon, and copy strings. Two reviewers
flagged the duplication after PRs #874 and #875 landed identical
changes in both files; the rule-of-three threshold is met.

Extract the shared parts compositionally rather than as one big config-
driven gate (the latter would just inline both gates' contents behind
8 props of indirection):

- frontend/src/hooks/useDismissalState.ts owns the localStorage
  dismissal pattern. Lives under hooks/ to dodge the
  react-refresh/only-export-components lint rule that would fire if a
  hook coexisted with components in the same file. Validates the stored
  timestamp via Number.isFinite so a hand-edited or stale-extension
  garbage value defaults to "show the upsell" instead of crashing.
- frontend/src/components/tierUpsell.tsx exports CompactBlurredLock,
  DismissedPill, and FullUpsellCard plus a shared TierGateProps
  interface. The compact-mode JSDoc lives on TierGateProps so the doc
  string lives in exactly one place.

PaidGate and AdmiralGate become ~50-line compositions reading like a
state machine. Public API of both gates is byte-stable: all 13+
consumers across the app continue to use <PaidGate featureName="X">
and <AdmiralGate featureName="X" compact> exactly as before.

Two pre-existing security/polish issues fixed in passing while there
is one source of truth for the affected JSX:

- FullUpsellCard's window.open now passes 'noopener,noreferrer' to
  prevent the destination tab from accessing window.opener (reverse
  tabnabbing).
- The Number.parseInt + Number.isFinite guard replaces a bare
  parseInt that would have happily accepted any prefix-numeric input.

Adds a Vitest spec for useDismissalState covering: empty / recent /
expired / non-numeric storage values, dismiss() / restore() side
effects, the 24h boundary on fresh mount, and key independence.
2026-05-02 04:01:54 -04:00
Anso b843b89ca4 feat(frontend): add LazyBoundary for chunk-load failure recovery (#875)
* feat(frontend): add LazyBoundary for chunk-load failure recovery

When a lazy chunk fetch fails, the existing top-level ErrorBoundary shows
"Something went wrong" with a "Try again" CTA. "Try again" cannot succeed
against a chunk URL that no longer exists on the server (typical post-
deploy case where the user's tab was opened against an older bundle).
The right remedy is to reload the tab so the browser fetches the new
hashed chunks emitted by the current build.

Add LazyBoundary, a section-local error boundary that:

- Detects chunk-load errors via a substring union covering Chrome / Edge
  ("Failed to fetch dynamically imported module"), Safari ("Importing a
  module script failed"), Firefox ("disallowed MIME type" thrown when a
  deploy serves SPA index.html for a missing chunk URL), older Webpack
  ("Error loading dynamically imported module"), and Vite ("Loading
  chunk/CSS chunk N failed").
- For chunk errors, renders a glass-card matching the LockCard aesthetic
  with an AlertTriangle icon, a "This part of Sencho needs a reload"
  message, and a Reload CTA that calls window.location.reload().
- For non-chunk runtime errors, falls back to "Something went wrong" +
  the error message + a Try again CTA. Try again is safe on this path
  because the lazy import has already resolved before the render error
  fires.
- Logs to console.error in componentDidCatch so the underlying failure
  is still observable.
- Has role="alert" so screen readers announce the failure.

Wrap every existing Suspense site (1 in SettingsPage, 7 in EditorLayout
including the security-history overlay, 1 in ResourcesView) with
LazyBoundary. The top-level ErrorBoundary remains the catch-all for
errors that escape the section-local boundary.

Includes a unit test enumerating each browser's documented chunk-load
message so a regression in any one runtime is caught early.

* fix(frontend): move isChunkLoadError to its own file to satisfy react-refresh/only-export-components
2026-05-02 03:40:45 -04:00
Anso 6fa0272e79 fix(frontend): replace post-dismissal blur in PaidGate / AdmiralGate with click-to-restore pill (#874)
* fix(frontend): replace post-dismissal blur with click-to-restore pill

PaidGate and AdmiralGate fell through to a "blurred children + small
pill" render when a user clicked Dismiss on the full-page upsell, for
the next 24h. The blurred-children path rendered the gated subtree,
so any lazy chunk behind the gate (FleetView, AuditLogView, etc.)
fetched on click during the dismissal window even though the user
saw only an obscured preview. This was the last lazy-chunk leakage
path remaining after the recent splitting work; CapabilityGate's
short-circuit refactor closed the others.

Split the dismissed branch from the compact branch. Compact mode
(used for inline list-item locks like a single SSO provider card) is
unchanged: its blur is intentional UX and the IP exposure is minor
because the children are tiny inline UI. Dismissed mode now renders
only a small pill in an empty 200px-tall area, with no children
mounted, so the lazy chunks behind the gate never fetch during the
dismissal window.

The pill is a button: clicking it removes the dismissal flag from
localStorage and re-renders the full upsell card. Users who dismissed
accidentally or want to revisit pricing have a way back without
clearing site data manually.

The dismissal flow itself is preserved: users who want to mute the
upsell pressure for 24h still can, they just get the static pill
instead of a blurred preview during that window.

* test(e2e): narrow upgrade-pill locator to file-upload button

The dismissed PaidGate branch introduced in this PR now renders a
<button> (was a <div>), which matched the same /upgrade to unlock/i
regex as the FileUploadDropzone button. Narrowing to
/upgrade to unlock upload/i targets only the file-upload pill and
resolves the strict-mode locator ambiguity.
2026-05-02 03:06:33 -04:00
Anso 6b74767388 fix(frontend): short-circuit CapabilityGate and extract shared LockCard (#873)
CapabilityGate previously rendered the gated children behind a blur
filter and overlay pill when the active node lacked the required
capability. Combined with the lazy-loaded views from the recent
splitting work, this meant the chunk for FleetView, AuditLogView,
HostConsole, etc. fetched on click even when the user could not use
the feature, defeating the click-time IP protection the lazy split
was meant to deliver. CapabilityGate was the only always-leaking gate
in the app.

Replace the blurred-children render with a clean glass lock card that
explains the version mismatch ("Fleet Management is not available on
this node. <node> is running v0.42.0. Upgrade the node to use this
feature."). Children are no longer rendered, so the lazy children
never mount and no chunk fetch happens. All 13 consumers (5 full-page
views, 7 settings sections, 1 inline panel in ResourcesView) get the
new behavior automatically; no consumer-side changes required.

Extract a shared LockCard primitive used by both CapabilityGate and
the existing TierLockedCard inside settings/SectionGate. The two were
95% identical (same glass-card chrome, same icon framing, same text
hierarchy) and would have drifted as the design evolved. The shared
primitive accepts an icon, title, and body, with an optional className
for layout overrides; the inner geometry is fixed so every lock state
in the app shares the same visual rhythm.

PaidGate and AdmiralGate still fall through to "blurred preview with
small pill" after a user clicks Dismiss on their full-page upsell
(24h localStorage window). That post-dismissal click-time leak is
intentionally out of scope here; closing it would require either
removing the dismissal flow or replacing the blurred-children render
with a static placeholder, both UX decisions deserving their own PR.
2026-05-02 02:33:53 -04:00
Anso a8d1a9d461 feat(frontend): code-split non-settings paid views and security overlay (#872)
Extends the settings code-splitting from PR #870 to the full-screen
views in EditorLayout. Six paid views (HostConsole, FleetView,
AuditLogView, ScheduledOperationsView, AutoUpdateReadinessView,
GlobalObservabilityView) and the SecurityHistoryView overlay used to
ship statically into the main bundle, so every Community user
downloaded ~300 kB raw / ~80 kB gzip of paid feature code on first
page load even if they never clicked those tabs.

Convert each to a lazy() declaration and wrap the call site in
Suspense with a small ViewSkeleton fallback. SecurityHistoryView is
an always-mounted overlay, so it is also conditionally mounted on
its open state to keep the lazy import from firing on EditorLayout's
first render.

GlobalObservabilityView is a free-tier feature with no internal gate;
it is split here purely for the bundle-size win, not for IP
protection. The other paid views still have their existing PaidGate /
AdmiralGate / CapabilityGate wrappers, which render a blurred preview
with upsell card rather than short-circuiting. When a tier-locked or
capability-missing operator opens one of those tabs, the chunk fetches
to render the blurred preview. The gate-short-circuit refactor is a
separate follow-up.

Build evidence: 7 new chunks total ~308 kB raw / ~83 kB gzip; main
bundle shrunk from 1,468 kB / 407 kB gzip to 1,165 kB / 332 kB gzip.
Combined with PR #870, Community users save ~390 kB raw / ~107 kB
gzip on initial load.
2026-05-02 02:13:02 -04:00
Anso fd05b5ef4b feat(frontend): code-split paid-tier settings sections (#870)
Every paid-tier React settings section (Users, Webhooks, Security,
Labels, ApiTokens, Registries, CloudBackup, NotificationRouting) was
statically imported into the bundle Community installs download.
SectionGate's runtime tier check hid the components from view but did
not gate the download, so paid feature JSX, copy, error messages, and
prop interfaces shipped to every Community user. Anyone could open
DevTools and read the source.

Convert each paid section to a lazy() declaration that imports the
component module on demand, and remove the corresponding re-exports
from settings/index.ts so rollup actually splits the chunk (without
this, the static export path through the barrel collapses the lazy
import back into the main bundle and emits an INEFFECTIVE_DYNAMIC_
IMPORT warning).

Suspense sits outside SectionGate intentionally: SectionGate short-
circuits to TierLockedCard synchronously for locked tiers, so the
lazy children never mount and no fallback flashes. The skeleton only
appears for the brief window between an unlocked section's chunk
request and its first render.

Build evidence: 8 new chunks total ~89 kB raw / ~27 kB gzip; main
bundle shrunk from 1,537 kB / 421 kB gzip to 1,468 kB / 407 kB gzip.
No INEFFECTIVE_DYNAMIC_IMPORT warnings remain. Dev-server runtime
test: AccountSection (free, eager) loaded as request 221 on app boot;
CloudBackupSection (paid, lazy) loaded as request 351 only after
clicking the sidebar entry.

Non-settings paid views (FleetView, AuditLogView, etc.) are still
static and remain a follow-up.
2026-05-02 01:50:21 -04:00
sencho-quartermaster[bot] 9ba6b604e3 docs: refresh screenshots (#869)
Co-authored-by: AnsoCode <18150933+AnsoCode@users.noreply.github.com>
2026-05-02 01:15:46 -04:00
sencho-quartermaster[bot] 50e9a55c11 chore(main): release 0.66.2 (#868)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com>
2026-05-02 00:59:42 -04:00
Anso 5e29649f3e chore: workflow hardening (husky+commitlint, dependency-review, stale, GHCR, mesh-sidecar digest) (#863)
* chore(repo): enforce Conventional Commits via husky and commitlint

release-please parses commit subjects on main to compute the next version
and regenerate CHANGELOG.md. A non-conforming commit silently breaks both,
so the format must be enforced at commit time, not by review.

Adds husky 9 to wire a commit-msg hook, commitlint with the conventional
config, and a small rule override (loosen subject length to 120 chars,
disable subject-case so existing imperative subjects keep passing). The
prepare script runs husky on npm install so contributors do not need to
configure it manually.

* ci: add dependency-review workflow

GitHub-native action that diffs the PR's manifests (package.json,
package-lock.json) against main and fails when a new transitive dep
introduces a high or critical CVE. Existing vulnerabilities tracked in
security/vex/sencho.openvex.json and the Trivy scan in ci.yml are not
re-flagged here.

Pinned to actions/dependency-review-action v4.9.0 by commit SHA. Comment
summaries are posted to the PR only on failure to keep the conversation
clean on green PRs.

* ci: add stale workflow for issues and pull requests

Marks issues and PRs as stale after 60 days of inactivity and closes 14
days later unless re-engaged. Issues labeled pinned, security, bug, or
tracking are exempt and never auto-closed; PRs labeled pinned or security
are exempt. Runs daily at 01:30 UTC and processes up to 30 items per run
to stay within the action's rate budget.

Pinned to actions/stale v10.2.0 by commit SHA.

* chore(repo): route new issues to docs, discussions, and security policy

Disables the blank-issue option and adds three contact links the issue
chooser surfaces above the bug-report and feature-request templates:
docs.sencho.io for setup questions, GitHub Discussions for open-ended
chat, and the repository security policy for private vulnerability
reports. This keeps the bug tracker focused on actionable bug reports
and feature requests instead of support questions.

* ci(docker): publish multi-arch image to GHCR alongside Docker Hub

Mirrors every released sencho and sencho-mesh image to ghcr.io with the
same tags, signing, and supply-chain attestations as the Docker Hub copy.
Same content; pull from whichever registry your environment prefers.

- Adds packages:write to both publish jobs so the auto-provisioned
  GITHUB_TOKEN can push to ghcr.io/studio-saelix/sencho and -mesh.
- Adds a second docker/login-action step authenticating to ghcr.io. The
  Docker Hub login still resolves credentials from the production env.
- docker/metadata-action now lists both image references; one buildx push
  attaches the manifest to both registries in a single round trip.
- Cosign keyless signing already loops over $TAGS, so adding the GHCR
  reference is enough to sign both digests.
- The cosign attest step now loops over both registry paths so SBOM (CDX
  + SPDX) and OpenVEX attestations are resolvable from either registry.

Quickstart and image-verification docs note GHCR as an alternative pull
source with identical content.

* fix(mesh-sidecar): pin base image by digest

The main Sencho Dockerfile pins every base image by sha256 digest so a
republish of an upstream tag cannot silently shift content into a
release. The mesh-sidecar Dockerfile pinned node:22-alpine by tag, which
is exactly the gap that pinning closes.

Resolves node:22-alpine to its current multi-arch index digest (covers
linux/amd64 and linux/arm64) and threads it through both build stages
via a single ARG so a future digest roll only edits one line. The inline
comment documents the resolution command.

The sidecar holds an outbound websocket and has no inbound HTTP
listener, so adding a HEALTHCHECK is intentionally out of scope: a
process-liveness probe would be tautological with Docker's restart
policy. The Dockerfile now records that decision so it does not get
reopened on every audit.

* ci(docker): use repository_owner for GHCR login username

github.actor varies by event source (the maintainer who merged the
release PR on tag pushes, github-actions[bot] on bot-driven runs, the
dispatcher on workflow_dispatch). The username field is metadata only;
GITHUB_TOKEN is what authenticates against GHCR. github.repository_owner
resolves to a fixed value (studio-saelix) on every event and matches
GitHub's own example workflows for GHCR push.

* chore(repo): tighten commit subject-case rule

Disabling subject-case entirely allowed accidental ALL-CAPS or
PascalCase subjects through. Restrict to "never upper-case or
pascal-case" instead, which preserves the lowercase / kebab-case
norm of this repo and lets sentence-case subjects (used sparingly
on main) keep passing.
2026-05-02 00:49:49 -04:00
sencho-quartermaster[bot] 7cde9917a5 docs: refresh screenshots (#866)
Co-authored-by: AnsoCode <18150933+AnsoCode@users.noreply.github.com>
2026-05-02 00:43:40 -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 7b7edc72bb ci: add workflow_dispatch trigger to release-please (#865)
release-please-action v5.0.0 (release-please library 17.6.0) returns
exit code 1 when it tries to create a GitHub Release whose tag already
exists, even when the underlying state is consistent (release already
published, manifest current, label state correct). This causes the
action to abort before the "open or update release PR" step, leaving
new conventional commits unprocessed.

Adding workflow_dispatch lets a maintainer re-run the action manually
from the Actions tab once the inconsistent state is cleared (typically
by removing the autorelease label from the prior release PR), without
needing to push a no-op commit to main to retrigger.

The workflow's internal state checks still run on every dispatch, so
manual triggers cannot double-publish a release. release-please-action
is already at latest (v5.0.0, 2026-04-22) so a version bump is not an
option until upstream ships a fix for the duplicate-tag handling.
2026-05-01 21:21:19 -04:00
sencho-quartermaster[bot] 344d24698e chore(main): release 0.66.1 (#864)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com>
2026-05-01 21:17:16 -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
sencho-quartermaster[bot] 593d916696 chore(main): release 0.66.0 (#853)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com>
2026-05-01 19:59:34 -04:00
Anso e5391e66cb feat(blueprints): add Fleet > Deployments tab UI, node labels, and docs (#861)
Implements the frontend layer for the Blueprint Model feature (backend
landed in PR #860). Fleet > Deployments tab is now live for Skipper+
users; Community users see the existing locked badge.

Key additions:
- blueprintsApi.ts: typed apiFetch wrappers (localOnly: true on all calls)
- BlueprintCatalog: featured hero, filter pills, classification-chipped tile grid
- BlueprintEditor: Monaco YAML editor with debounced live classification,
  label/node selector, three-mode drift radio cards, create/edit modes
- BlueprintDeploymentTable: per-node status rows with action buttons
  (Confirm deploy, Retry, Withdraw/Evict, DATA PINNED HERE for stateful)
- EvictionDialog: dual-affordance (Snapshot then evict / Evict and destroy)
- StateReviewDialog: fresh-deploy acceptance gate for stateful blueprints
- BlueprintClassificationBanner: real-time stateless/stateful/unknown banner
- DeploymentsTab: wires catalog, empty state, and create dialog
- BlueprintDetail: Sheet with Apply/Edit/overflow, themed delete dialog
- NodeLabelPicker + NodeLabelPill: label CRUD per node in NodeManager
- FleetView: gates Deployments tab behind isPaid; mounts DeploymentsTab
- NodeManager: Labels column with NodeLabelPicker (Skipper+ users)
- docs/features/blueprint-model.mdx + docs.json entry + 6 screenshots
2026-05-01 19:47:05 -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 f62716f557 refactor(design): align typography, colors, and card surfaces to DESIGN.md (#859)
* refactor(design): align surface tokens to DESIGN.md §2

* refactor(design): canonicalize tracked-mono kickers and display rungs

* refactor(design): collapse to five-slot palette and align card surfaces
2026-05-01 03:01:00 -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 b8437e8780 feat(fleet): §16 orchestrator tab foundation (Deployments, Federation, Secrets) (#856)
Reserves three navigable but non-functional tab slots on the Fleet page so
each future orchestrator surface can land as a tab content swap rather than
a navigation redesign. Each tab opens a coming-soon placeholder card listing
the planned actions for that surface.
2026-05-01 00:10:56 -04:00
Anso a25acbec7c feat(editor): opt-in diff preview before save (#855)
* feat(editor): add useComposeDiffPreviewEnabled hook

* feat(editor): add ComposeDiffPreviewDialog component

* fix(editor): replace HTML entity with Unicode arrow in ComposeDiffPreviewDialog

* feat(editor): add diff preview toggle to Appearance settings

Added a new 'Diff preview before save' toggle in the Display section of the Appearance settings panel. Users can now enable or disable the side-by-side diff view before compose and env file edits are saved to disk.

* feat(editor): wire diff preview dialog into compose save flow

* fix(editor): snapshot diff content at open time and fix event name

- Fix useComposeDiffPreviewEnabled and useDeployFeedbackEnabled to use
  the canonical SENCHO_SETTINGS_CHANGED constant from @/lib/events
  instead of the hardcoded string literal (wrong value)
- Snapshot language, original, modified, and fileName into diffPreview
  state at click time to prevent tab-switching from corrupting dialog
  content mid-review
- Remove React.MouseEvent from diffPreview state; pass a no-op stub to
  deployStack in the confirm path (preventDefault/stopPropagation are
  no-ops on an already-settled event anyway)
- Add diff-modal screenshot and document the feature in editor.mdx and
  settings.mdx

* docs(editor): add settings-toggle screenshot for diff preview feature
2026-04-30 22:01:17 -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 a0bf5b5bf5 feat(sidebar): bulk stack operations (#854)
* feat(sidebar): bulk stack operations (select, start/stop/restart/update)

- Add ⊞ bulk mode toggle in SidebarActions (cyan active state, tooltip "Bulk
  mode (B)"); keyboard shortcut B toggles, Esc exits, Ctrl+A selects all
  visible (chip-filtered) stacks
- Reserved checkbox column in StackRow becomes visible and interactive in bulk
  mode; clicking a row in bulk mode toggles selection instead of opening the
  stack; kebab and context-menu still work in either mode
- SidebarBulkBar appears below filter chips when >=1 stack selected: shows
  count, Start / Stop / Restart / Update actions; Update is disabled with a
  Skipper TierBadge for Community licenses
- useBulkStackActions hook fans out operations via Promise.allSettled and
  surfaces an aggregate toast ("3 of 4 restarted; 1 failed: plex")
- Bulk update enforced Skipper-gated frontend-side (isPaid check in hook) and
  sends x-bulk-mode header for backend defense-in-depth
- Extract isInputFocused / isPaletteOpen to lib/keyboard-guards.ts; both
  useStackKeyboardShortcuts and the new bulk keyboard effect now share the
  same guards instead of duplicating the logic
- chipFilteredFiles captured via useRef in bulk keyboard effect so the listener
  is not torn down and re-added on every status-poll cycle

* fix(sidebar): separate TooltipProviders for bulk and scan icon buttons

Wrapping both icon buttons in a single TooltipProvider made them
render as one flex child, collapsing the gap-2 between them.
Splitting into two independent TooltipProviders restores the 8px
gap and right padding of the scan button.
2026-04-30 19:53:12 -04:00
Anso 4c0efcb9a8 feat(sidebar): §14 sidebar orchestration, filter chips, pinned rail, trailing column (#850)
* feat(sidebar): §14 sidebar orchestration (filter chips, pinned rail, trailing column)

- Add All / Up / Down / Updates filter chips with live counts; active chip
  filters the list; chip-filtered files computed in EditorLayout with useMemo
- Surface the PINNED group with a 3px cyan left rail and glow via the
  sidebarPinnedGroupRail token; reuses the brand token already on the active row
- Compact brand row from three stacked elements to a single 44px horizontal bar
- Restructure StackRow trailing column as fixed slots: label dots (max 3 + +N
  overflow), update-dot | git-pending icon (priority order), kebab; add reserved
  invisible checkbox slot for PR2 bulk mode
- Export statusText / statusColor from StackRow and reuse them in StackList
  remote-results section to remove the duplicate inline logic
- Lift filterChip state and chip-filtered files to EditorLayout; remove
  filterChip from StackListProps to eliminate the dual-path redundancy
- Remove unused labels param from buildGroups and the void labels workaround
- Wrap filteredFiles in useMemo so filterCounts memo is not defeated on every
  render

* fix(sidebar): move statusText and statusColor to stack-status-utils

react-refresh/only-export-components requires component files to export
only components. Move the two utility functions and StackRowStatus type
to a dedicated stack-status-utils.ts so StackRow.tsx is a pure component
module. Update StackList.tsx and EditorLayout.tsx to import from the new
source directly.
2026-04-30 19:37:49 -04:00
Anso eead195529 feat(settings): dress the page to match the audit (#849)
* feat(settings): dress the page to match the audit (cyan rail, italic serif, two-column rows)

Brings the full-page Settings route into the Sencho voice. The page now
opens with a full-width PageMasthead (cyan rail, mono crumb, italic
serif title, contextual stat strip) above a sidebar and main-content
panel, each as a rounded-xl card inset on the dark background.

Sidebar drops the duplicate "Settings" header and the candy tier badges.
Group headers carry mono labels with visible/total counts; gated rows
get a neutral uppercase lock chip and dim. Active rows keep the cyan
2px rail.

Five new primitives (SettingsSection, SettingsField, SettingsCallout,
SettingsActions / SettingsPrimaryButton, TierLockChip) replace the
stacked label-input-help shadcn defaults and the per-section ad-hoc
chrome. AccountSection, AppearanceSection, LicenseSection, SystemSection,
NotificationsSection, DeveloperSection, AppStoreSection, AboutSection,
and SupportSection are migrated to the new layout. The list-driven
sections (Webhooks, Routing, Users, Labels, Security, CloudBackup,
ApiTokens, Registries, NodeManager, SSO) keep their list cards but get
the new chrome and primary CTAs.

Each section can publish contextual stats to the masthead via a small
context channel: 2FA state on Account, plan/trial/renews on License,
edited count on System, channel counts on Notifications, etc.

* refactor(settings): drop react-router-dom and align with DESIGN.md

The Settings page was the only surface using react-router-dom for sub-section
navigation. Every other primary view (Home, Fleet, Resources, App Store,
Schedules, etc.) drives view switching through a single activeView useState in
EditorLayout. This change removes the dependency end-to-end:

- App.tsx drops BrowserRouter
- EditorLayout adds 'settings' to the activeView union; SettingsPage renders
  inside the same flex-1 overflow-y-auto p-6 wrapper as siblings
- UserProfileDropdown receives an onOpenSettings callback instead of
  useNavigate. SettingsPage owns currentSection via props lifted to
  EditorLayout, so cross-component navigation (openLabelManager,
  onManageNodes, ConfigurationStatus rows) can route to a sub-section
- SettingsSidebar items become buttons (no more NavLink); SectionGate's
  redirect-on-invisible falls back through SettingsPage's safeSection memo
- e2e/nodes.spec.ts updates the Nodes selector from link to button role
- react-router-dom removed from package.json + package-lock.json

The visual treatment is brought into alignment with frontend/DESIGN.md,
which was rewritten this week to be the normative extract of the audit:

- PageMasthead: title text-3xl → text-[22px] Section rung italic; kicker
  11px → 10px Label rung; stat label tracking 0.22em → 0.18em; stat value
  font-medium for mono Stat-rung family discipline
- SettingsField helper: mono → sans Body rung 14/22; success tone now uses
  --success green (was incorrectly mapped to brand cyan)
- SettingsCallout: title tracking 0.18em; subtitle Body rung 14px; success
  tone now genuinely uses --success green; new brand tone for promotional
  callouts (Trial CTA, Admiral upgrade) that should read cyan
- SettingsActions: SettingsPrimaryButton renders mono uppercase tracked,
  size sm by default. DESIGN §9.10 requires "small mono uppercase, cyan-
  filled" for every Settings primary CTA
- TierLockChip: 9px → 10px Label rung floor
- SettingsSidebar: group header tracking 0.18em; ⌘K kbd 9px → 10px;
  aside gains text-card-foreground transition-colors per §10 canonical
  card class
- SettingsPage main panel: text-card-foreground transition-colors added;
  uses h-full overflow-auto p-6 to mirror FleetView's wrapper rhythm
- Field rows, section headers, action rows now consume var(--density-*)
  tokens with literal fallbacks so Settings respects the comfortable/
  compact toggle

* fix(e2e): update mfa openAccountSettings to match settings redesign

Settings now opens to the Account section by default when accessed from
the profile dropdown, and the Account section no longer renders an h2
heading element. Update the openAccountSettings helper to open the
correct section and assert on the Password h3 heading that SettingsSection
renders instead.

* test(e2e): fix MFA enrolment assertion after settings redesign

The 2FA enrolment badge was replaced with a kicker/field pattern.
Assert on the 'enrolled' text that the new design renders instead of
the removed Enabled badge.

* test(e2e): fix low-backup-codes warning assertions after settings redesign

Update two assertions in the 'low backup codes warning' test that
referenced UI text removed in the settings redesign:
- '1 backup code remaining' -> '1 remaining' (SettingsField body text)
- 'Regenerate now' button -> callout subtitle text, which uniquely
  identifies the zero-codes error card without hitting strict-mode
  from two identically-labelled Regenerate buttons on the page

* test(e2e): navigate to root before re-opening settings for mock refresh

The settings redesign uses a nested full-page route. Navigating to the
same URL a second time does not remount the component, so AccountSection
retains cached MFA state and the 0-codes branch never fetches. A
page.goto('/') ensures full unmount before the second openAccountSettings
call, so the refreshed mock is actually hit.

* test(e2e): scroll zero-codes callout into view before asserting visibility

The callout sits below the Disable 2FA section in the MFA settings page
and is scrolled out of the clipped content area on initial render.
scrollIntoViewIfNeeded() brings it into the visible viewport before the
toBeVisible assertion.

* test(e2e): scroll Radix ScrollArea viewport for zero-codes callout assertion

The settings page wraps content in a Radix ScrollArea whose Root has
overflow:hidden, so the browser's native scrollIntoView cannot scroll
the inner viewport. Wait for the callout to attach (confirms mock data
loaded), then programmatically set scrollTop on the Radix viewport
element before asserting visibility.

* test(e2e): use toBeAttached for zero-codes callout to avoid Radix clip issue

The callout renders below the Disable 2FA section, outside the visible
clip area of the Radix ScrollArea Root (overflow:hidden) on a standard
viewport. Playwright's visibility check uses the clip intersection, so
toBeVisible() fails even after programmatic scroll. toBeAttached()
confirms the component rendered the warning card for backupCodesRemaining:0
without depending on the element's scroll position.
2026-04-30 19:37:38 -04:00
Anso 9a1c043189 refactor(settings): replace modal with nested full-page route (#848)
* refactor(settings): replace modal with nested full-page route

Settings sections are now URL-addressable at /settings/:sectionId, rendered
nested inside EditorLayout alongside the stack sidebar. Browser back/forward
navigates between sections. Deep links (e.g. /settings/cloud-backup) load
the section directly on hard reload.

- Add react-router-dom v7; BrowserRouter wraps the full app tree
- New SettingsPage (scroll memory, Cmd+K palette), SettingsSidebar (NavLink
  active styling, back-arrow), SectionGate (visibility + tier lock card)
- Rename SectionId 'appstore' to 'app-store' so slug === SectionId
- Decouple SystemSection, DeveloperSection, AppStoreSection from modal-
  passed props; each fetches its own data on mount
- Replace onLabelsChanged prop chain with SENCHO_LABELS_CHANGED window event
- Drop onOpenSettings prop from UserProfileDropdown, HomeDashboard,
  ConfigurationStatus; each calls useNavigate directly
- Delete SettingsModal.tsx

* fix(settings): validate sectionId against registry before property write

Prevents prototype pollution (CodeQL js/remote-property-injection #243).
URL param sectionId is checked against SETTINGS_ITEMS before being used
as a property key on scrollPositionsRef.

* fix(settings): eliminate remote property injection via Map and registry-sourced key

Two-part fix for CodeQL js/remote-property-injection:

1. currentSection is now derived from SETTINGS_ITEMS.find().id (trusted
   registry data) instead of the raw sectionId URL param. The tainted
   string never flows into any property access.

2. scrollPositionsRef uses Map<SectionId, number> with .get()/.set()
   instead of a plain object. Map operations do not write to the prototype
   chain, removing the prototype pollution vector entirely.

* test(e2e): align settings selectors with full-page route

The settings refactor (4475afd) replaced the modal with a nested route.
The new sidebar renders sub-sections as NavLinks (role link, not button)
and adds a "Filter settings" button that collides with the loose
/settings/i regex used in mfa and nodes specs.

- Use exact 'Settings' match for the profile-dropdown menu row
- Switch the Nodes sub-section selector from button to link role
2026-04-30 12:57:02 -04:00
dependabot[bot] 3c30c2befe chore(deps): bump the all-npm-backend group in /backend with 6 updates (#846)
Bumps the all-npm-backend group in /backend with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [isomorphic-git](https://github.com/isomorphic-git/isomorphic-git) | `1.37.5` | `1.37.6` |
| [openid-client](https://github.com/panva/openid-client) | `6.8.3` | `6.8.4` |
| [tar-stream](https://github.com/mafintosh/tar-stream) | `3.1.8` | `3.2.0` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.59.0` | `8.59.1` |
| [@aws-sdk/client-ecr](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ecr) | `3.1037.0` | `3.1038.0` |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1037.0` | `3.1038.0` |


Updates `isomorphic-git` from 1.37.5 to 1.37.6
- [Release notes](https://github.com/isomorphic-git/isomorphic-git/releases)
- [Commits](https://github.com/isomorphic-git/isomorphic-git/compare/v1.37.5...v1.37.6)

Updates `openid-client` from 6.8.3 to 6.8.4
- [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.3...v6.8.4)

Updates `tar-stream` from 3.1.8 to 3.2.0
- [Commits](https://github.com/mafintosh/tar-stream/compare/v3.1.8...v3.2.0)

Updates `typescript-eslint` from 8.59.0 to 8.59.1
- [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.1/packages/typescript-eslint)

Updates `@aws-sdk/client-ecr` from 3.1037.0 to 3.1038.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.1038.0/clients/client-ecr)

Updates `@aws-sdk/client-s3` from 3.1037.0 to 3.1038.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.1038.0/clients/client-s3)

---
updated-dependencies:
- dependency-name: isomorphic-git
  dependency-version: 1.37.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: openid-client
  dependency-version: 6.8.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: tar-stream
  dependency-version: 3.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: typescript-eslint
  dependency-version: 8.59.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: "@aws-sdk/client-ecr"
  dependency-version: 3.1038.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.1038.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-04-29 16:46:57 -04:00
dependabot[bot] d6ddf2ae30 chore(deps): bump the all-npm-frontend group in /frontend with 3 updates (#845)
Bumps the all-npm-frontend group in /frontend with 3 updates: [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react), [rollup-plugin-visualizer](https://github.com/btd/rollup-plugin-visualizer) and [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint).


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

Updates `rollup-plugin-visualizer` from 6.0.11 to 7.0.1
- [Changelog](https://github.com/btd/rollup-plugin-visualizer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/btd/rollup-plugin-visualizer/compare/v6.0.11...v7.0.1)

Updates `typescript-eslint` from 8.59.0 to 8.59.1
- [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.1/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: rollup-plugin-visualizer
  dependency-version: 7.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: all-npm-frontend
- dependency-name: typescript-eslint
  dependency-version: 8.59.1
  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-04-29 16:32:52 -04:00
dependabot[bot] cb0161b77e chore(deps): bump contributor-assistant/github-action (#844)
Bumps the all-actions group with 1 update in the / directory: [contributor-assistant/github-action](https://github.com/contributor-assistant/github-action).


Updates `contributor-assistant/github-action` from 2.3.1 to 2.6.1
- [Release notes](https://github.com/contributor-assistant/github-action/releases)
- [Commits](https://github.com/contributor-assistant/github-action/compare/a895a435fcce79ecf28fbce61a4ef0f0dabc9853...ca4a40a7d1004f18d9960b404b97e5f30a505a08)

---
updated-dependencies:
- dependency-name: contributor-assistant/github-action
  dependency-version: 2.6.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  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-04-29 16:32:22 -04:00
SaelixCode b5d0e7e4db chore: verify docs sync in new organization 2026-04-29 15:53:45 -04:00
SaelixCode 0f45717d08 Merge branch 'main' of https://github.com/studio-saelix/sencho 2026-04-29 15:53:45 -04:00
Anso 9274584255 chore: migration heartbeat - verify CI (#847)
* chore: migration heartbeat - verify CI and GitHub App sync

* chore: add AnsoCode to CLA allowlist
2026-04-29 15:52:23 -04:00
SaelixCode a07790b190 chore: migration heartbeat - verify CI and GitHub App sync 2026-04-29 15:18:53 -04:00
SaelixCode 2c85bc7219 Merge branch 'main' of https://github.com/AnsoCode/Sencho
# Conflicts:
#	package.json
2026-04-29 09:28:43 -04:00
SaelixCode 3da0aa6036 chore: migrate repository URLs from AnsoCode/Sencho to studio-saelix/sencho
Updates all hardcoded GitHub repository references across 21 files:
- package.json: repository URL, bugs URL, homepage, description, author
- CONTRIBUTING.md: bug report template URL
- SECURITY.md: advisory URL, cosign cert-identity regexp
- .github/CODEOWNERS: @AnsoCode -> @studio-saelix/maintainers
- .github/workflows/ci.yml: repositories scope (Sencho -> sencho), docs-sync git URL
- .github/workflows/cla.yml: path-to-document URL
- .github/workflows/docker-publish.yml: cosign verify comment
- frontend/**/*.tsx: issues and changelog links (3 components)
- frontend/public/.well-known/security.txt: Contact and Policy URLs
- security/vex/sencho.openvex.json: @id field
- docs/openapi.yaml: license URL
- docs/docs.json: navbar and footer GitHub links (5 instances)
- docs/security.mdx: advisory and SECURITY.md links
- docs/reference/verifying-images.mdx: repo link + cosign regexp + legacy identity note
- docs/reference/contact.mdx: issues, LICENSE, advisory, policy, CoC links
- docs/reference/security-advisories.mdx: releases link
- docs/operations/verifying-images.mdx: cosign regexps and VEX download URL (6 instances)
- docs/operations/upgrade.mdx: releases links (2 instances)
- backend/src/utils/version-check.ts: GitHub Releases API endpoint

CHANGELOG.md intentionally excluded (release-please managed).
Legacy cosign identity note added for pre-migration image verification.
2026-04-29 09:24:20 -04:00
sencho-quartermaster[bot] a2d06a2ef6 docs: refresh screenshots (#843)
Co-authored-by: AnsoCode <18150933+AnsoCode@users.noreply.github.com>
2026-04-29 05:54:52 +00:00
sencho-quartermaster[bot] bc2b0a9725 chore(main): release 0.65.1 (#840)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com>
2026-04-29 01:53:27 -04:00
SaelixCode c8287e43a0 chore(cla): add sencho-quartermaster[bot] to allowlist 2026-04-29 01:49:30 -04:00