mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
d69fb9f1da24ef257d58ce9682fad308be2f86ff
1031 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
b6b154cb99 |
chore(main): release 0.67.1 (#885)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com> |
||
|
|
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. |
||
|
|
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. |
||
|
|
577acc056b |
docs: refresh screenshots (#882)
Co-authored-by: AnsoCode <18150933+AnsoCode@users.noreply.github.com> |
||
|
|
d810a1bacc |
chore(main): release 0.67.0 (#871)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com> |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
9ba6b604e3 |
docs: refresh screenshots (#869)
Co-authored-by: AnsoCode <18150933+AnsoCode@users.noreply.github.com> |
||
|
|
50e9a55c11 |
chore(main): release 0.66.2 (#868)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com> |
||
|
|
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. |
||
|
|
7cde9917a5 |
docs: refresh screenshots (#866)
Co-authored-by: AnsoCode <18150933+AnsoCode@users.noreply.github.com> |
||
|
|
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. |
||
|
|
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. |
||
|
|
344d24698e |
chore(main): release 0.66.1 (#864)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com> |
||
|
|
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. |
||
|
|
593d916696 |
chore(main): release 0.66.0 (#853)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com> |
||
|
|
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 |
||
|
|
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.
|
||
|
|
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 |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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 (
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
b5d0e7e4db | chore: verify docs sync in new organization | ||
|
|
0f45717d08 | Merge branch 'main' of https://github.com/studio-saelix/sencho | ||
|
|
9274584255 |
chore: migration heartbeat - verify CI (#847)
* chore: migration heartbeat - verify CI and GitHub App sync * chore: add AnsoCode to CLA allowlist |
||
|
|
a07790b190 | chore: migration heartbeat - verify CI and GitHub App sync | ||
|
|
2c85bc7219 |
Merge branch 'main' of https://github.com/AnsoCode/Sencho
# Conflicts: # package.json |
||
|
|
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. |
||
|
|
a2d06a2ef6 |
docs: refresh screenshots (#843)
Co-authored-by: AnsoCode <18150933+AnsoCode@users.noreply.github.com> |
||
|
|
bc2b0a9725 |
chore(main): release 0.65.1 (#840)
Co-authored-by: sencho-quartermaster[bot] <275163604+sencho-quartermaster[bot]@users.noreply.github.com> |
||
|
|
c8287e43a0 | chore(cla): add sencho-quartermaster[bot] to allowlist |