Every filesystem operation against user compose folders (save, create,
deploy, update, rollback, template install, fleet snapshot restore)
previously failed with EACCES whenever a stack container had chowned
its own bind mount to another UID, which is extremely common with
linuxserver/* images and anything that runs as root by default.
Running Sencho as root eliminates the entire class of permission bugs
at the source and matches the default posture of Portainer, Dockge,
Komodo, and Yacht. Mounting /var/run/docker.sock is already equivalent
to root-on-host, so the previous non-root hardening provided essentially
no additional isolation while breaking real features.
Changes:
- docker-entrypoint.sh: default path stays root, no GID dance, no
privilege drop. Opt-out via SENCHO_USER=sencho restores the legacy
behavior bit-for-bit (chown data dir, match Docker socket GID,
su-exec to the user). Fails fast if SENCHO_USER names a nonexistent
account. Kubernetes / OpenShift forced-non-root compat preserved via
the existing id -u = 0 guard.
- FileSystemService: delete forceDeleteViaDocker (the ~40-line helper
that shelled out to an alpine container to work around EACCES during
deleteStack) and simplify deleteStack to a single fsPromises.rm call.
Tests updated accordingly.
- Dockerfile: keep the sencho user+group pre-created so the opt-out
path works out of the box; comments updated to document the new
default.
- Docs: new "Container user" section in configuration.mdx documenting
the root default and the SENCHO_USER opt-out; troubleshooting and
self-hosting updated to match.
The runtime stage's `apk upgrade --no-cache` layer was being reused from
the buildx gha/registry cache indefinitely, so newly-patched Alpine
packages (e.g. openssl CVE fixes) could sit behind a stale layer until
an unrelated Dockerfile change invalidated it by coincidence. This
surfaced as Trivy hard-failing on CVE-2026-28390 (libcrypto3/libssl3
DoS in CMS) on the github-app-token-migration PR, even though upstream
Alpine had already published 3.5.6-r0 with the fix.
Add an APK_CACHE_BUST build-arg that CI sets to the current UTC date
(YYYY-MM-DD). Each calendar day the arg value changes, which changes
the RUN layer's hash, which forces buildx to re-execute `apk upgrade`
and pick up whatever Alpine has published that day. All three build
invocations (PR docker-validate, release pre-publish scan, release
multi-arch push) pass the same value, so the scan and the published
artifact always share a layer and Trivy never scans stale bits.
Default value `unset` lets local developers `docker build` without
the arg; production CI always supplies the date.
* fix(fleet): resolve version detection pipeline for Docker builds
The Dockerfile backend-builder stage was missing a COPY of the root
package.json, causing generate-version.js to fall back to "0.0.0-dev"
at build time. At runtime, the filesystem walk also failed (root
package.json not in the final image), producing the string "unknown"
which the frontend rendered as "vunknown".
Changes:
- Dockerfile: copy root package.json into backend-builder stage
- CapabilityRegistry: return null (not "unknown") for unresolvable
versions; add isValidVersion() type guard; normalize remote meta
responses to strip "unknown"/"0.0.0-dev" sentinel values
- Fleet endpoints: hoist gateway version validation outside per-node
loops; treat unresolvable remote versions as "potentially outdated"
instead of silently marking them up to date
- FleetView: guard all version display points (card badge, update
button, gateway label, modal columns) via shared formatVersion()
- EditorLayout, CapabilityGate: use shared isValidVersion utility
- New frontend/src/lib/version.ts shared utility
- Docs: add troubleshooting section for version display edge cases
- Screenshots: updated Fleet Overview and Node Updates modal
* docs: update fleet node updates screenshot with live remote node
Compose v2.40.3 was compiled with Go 1.24.9, grpc 1.74.2, and
x/crypto 0.38.0 — all below the minimum safe versions identified
in the March 2026 security audit. Upgrading to v5.1.1 (Go 1.25.8,
x/crypto 0.46.0) resolves CVE-2025-68121, CVE-2025-61726,
CVE-2025-61729, CVE-2026-25679, and CVE-2025-47913.
CVE-2026-33186 (grpc ≥1.79.3) remains unresolved — no upstream
Docker CLI or Compose release includes grpc past 1.78.0 yet.
CVE-2026-33671 (picomatch) and CVE-2026-33747/33748 (buildkit) were
already patched in prior releases.
- Upgrade Docker base image from node:20-alpine to node:22-alpine
(Node 22.22.2, Alpine 3.23.3) to resolve 31 CVEs flagged by Docker Scout
- Add apk upgrade to runtime stage for latest Alpine security patches
- Force dompurify to 3.3.3 via npm overrides to fix two XSS advisories
(transitive dep of monaco-editor pinned at 3.2.7)
* feat: add RBAC viewer accounts, atomic deployments, and fleet-wide backups (Pro)
Introduces three Pro-tier features:
- RBAC: Multi-user system with admin/viewer roles, user management UI,
automatic migration from single-admin credentials, viewer restrictions
across the entire UI (read-only editor, hidden action buttons)
- Atomic Deployments: Pre-deploy file backup to .sencho-backup/, automatic
rollback on health probe failure, manual rollback button, health probes
added to stack updates, webhook-triggered deploys use atomic rollback
- Fleet-Wide Backups: Point-in-time snapshots of compose files across all
nodes (local + remote), stored centrally in SQLite, per-stack restore
with optional redeploy, graceful handling of offline nodes
* fix(settings): use correct ProGate prop name in UsersSection
* fix(settings): remove unused isPro prop from UsersSection
* fix(auth): fetch user info after login and setup so isAdmin is set correctly
* feat(pricing): revise pricing strategy and enforce variant-based seat limits
Raise Personal Pro from $49/yr to $69/yr with 3 viewer seats (up from 1).
Add $15/mo billing option for Team Pro. Mark lifetime pricing as a
90-day early-adopter offer. Store Lemon Squeezy variant_name on
activation/validation and enforce seat limits server-side per variant.
* feat(licensing): add Lemon Squeezy checkout, webhook, and billing portal integration
Server-side checkout URL generation (POST /api/checkout) with admin email
pre-fill and instance_id custom data. HMAC-SHA256 verified webhook endpoint
(POST /api/webhooks/lemonsqueezy) handling order, subscription, and payment
lifecycle events for automatic license activation. Customer billing portal
link stored from webhook events and exposed via GET /api/billing/portal.
In-app checkout buttons in Settings with manual license key fallback.
* fix(licensing): exempt Lemon Squeezy webhook from auth middleware
The catch-all auth middleware on /api/* was blocking the public webhook
endpoint. Added /webhooks/lemonsqueezy to the exemption list alongside
/auth/* and /webhooks/:id/trigger.
* feat(pricing): update pricing to final live rates
Personal Pro: $7.99/month, $69.99/year, $249 lifetime.
Team Pro: $49.99/month, $499.99/year, $1,499 lifetime.
Added personal_monthly checkout variant across backend, frontend, and website.
* refactor(licensing): remove server-side checkout/webhook for self-hosted model
Sencho is self-hosted — each user runs their own instance, so there is
no central server to receive webhooks or hold the store API key. Replaced
in-app checkout buttons with a "View Pricing" redirect to sencho.io and
kept manual license key activation as the primary flow.
- Delete LemonSqueezyService (checkout, webhook, HMAC verification)
- Remove POST /api/checkout, GET /api/billing/portal, POST /api/webhooks/lemonsqueezy
- Remove raw body parser and auth exemption for webhook route
- Remove all LEMONSQUEEZY_* env vars from .env.example
- Replace checkout buttons in SettingsModal with single "View Pricing" button
- Simplify LicenseContext checkout to open sencho.io pricing page
- Update licensing docs to reflect website-based purchase flow
* chore: normalize em-dashes to hyphens across codebase (linter)
* chore: remove accidentally tracked directories from index
* feat: add license gating system with Lemon Squeezy integration
Add Community/Pro tier infrastructure:
- LicenseService singleton with Lemon Squeezy license API integration
- /api/license endpoints (GET info, POST activate/deactivate/validate)
- 14-day Pro trial activated automatically on first boot
- 72-hour periodic validation with 30-day offline grace period
- LicenseContext provider for frontend tier awareness
- License settings tab with activation UI and status display
- ProBadge and ProGate reusable components for feature gating
- requirePro per-route guard for backend Pro-only endpoints
- Proxy bypass for /api/license routes (local-only, never proxied)
* feat: add user profile dropdown and reorganize top navigation
- Create UserProfileDropdown component with settings, billing, theme
toggle (System/Light/Dark), documentation links, and logout button
- Remove logout button from sidebar header
- Remove standalone settings button from top bar
- Move theme toggle from Settings modal to profile dropdown
- Inject app version via Vite define from root package.json
- Add globals.d.ts for __APP_VERSION__ type declaration
* refactor(settings): remove appearance tab from settings modal
Theme toggle was moved to the User Profile Dropdown in the previous
commit. Remove the now-redundant Appearance section, its nav button,
and the unused theme/setTheme props from SettingsModal.
* feat: add fleet view dashboard and about settings section
Fleet Overview: aggregates all nodes into a card grid showing status,
container counts, CPU/RAM/disk usage bars. Pro tier unlocks stack
drill-down with auto-refresh (30s). Backend endpoints /api/fleet/overview
and /api/fleet/node/:nodeId/stacks query nodes in parallel.
About section in Settings: displays version, license tier, status,
instance ID, and links to docs/changelog/issues.
Sidebar perf fix: stack status fetches now run in parallel via
Promise.allSettled instead of sequential for-loop, significantly
reducing load time for nodes with many stacks.
Also removes version number from User Profile Dropdown (now in About).
* fix(ci): resolve Docker build and E2E test failures
- Copy root package.json into frontend build stage so vite.config.ts
can read the app version during Docker multi-stage build.
- Update auth E2E test: logout button moved into User Profile Dropdown.
- Update nodes E2E test: Settings button moved into User Profile Dropdown.
* chore(deps): upgrade ESLint 9 → 10 with plugin compatibility fixes
- eslint: ^9 → ^10.0.0 (backend + frontend)
- @eslint/js: ^9 → ^10.0.0 (backend + frontend)
- eslint-plugin-react-refresh: ^0.4.24 → ^0.5.2 (ESM, config factory API)
- Update frontend eslint.config.js: destructured import for react-refresh,
call configs.vite() as factory function (0.5 API change)
- Downgrade new ESLint 10 rules (no-useless-assignment, preserve-caught-error)
to warnings for existing code patterns
- eslint-plugin-react-hooks stays at 7.0.1 (stable) with --legacy-peer-deps
until a stable release adds ESLint 10 peer support
* chore(deps): upgrade recharts 2.x to 3.8 with chart.tsx type fixes
recharts 3.x moved Tooltip/Legend props to context-based API.
Updated chart.tsx to use explicit prop interfaces with internal
recharts type imports (LegendPayload, TooltipPayload, TooltipFormatter).
* chore(deps): upgrade TypeScript 5.9 to 6.0
- Remove deprecated baseUrl from frontend tsconfig (paths works standalone in TS 6)
- Add react-is dependency required by recharts 3.x at build time
- Backend and frontend both compile and lint cleanly
* chore(deps): upgrade Vite 7.3 to 8.0 and @vitejs/plugin-react to 6.0
Vite 8 replaces Rollup+esbuild with Rolldown, significantly improving
build speed (~2s vs ~18s). No config changes required.
* fix(ci): add .npmrc with legacy-peer-deps for CI and Docker builds
typescript-eslint@8.x requires typescript <6.0.0 and
eslint-plugin-react-hooks@7.0.1 requires eslint <=9. Until upstream
packages release compatible versions, legacy-peer-deps is needed.
* docs: add logo assets and re-ignore CLAUDE.md
* fix(ci): copy .npmrc into prod-deps Docker stage
The prod-deps stage also runs npm ci with backend/package.json but was
missing the .npmrc needed to bypass peer dep conflicts.
* fix(docker): fix xx cross-compilation sysroot for node-pty and C++ modules
Three issues in the prod-deps cross-compilation stage:
1. xx-apk had `gcc` (C only) instead of `g++` — all three native modules
(bcrypt, better-sqlite3, node-pty) use C++, so libstdc++ headers must
be present in the target sysroot for xx-clang++ to link against them.
2. Missing `linux-headers` in the target sysroot — node-pty requires
<pty.h> and <termios.h> which live in the Linux kernel headers package.
3. Missing AR=xx-ar — node-gyp uses `ar` to create static archives during
the native build; without this override it falls back to the host ar
(amd64), producing wrong-arch .a files that fail at the link step.
* fix(docker): use native g++ for same-platform builds, xx-clang only for cross
xx-clang introduces sysroot flags that break node-gyp header resolution on
Alpine when TARGETARCH == BUILDARCH (e.g. the docker-validate CI job which
builds amd64-only). The native path now uses the standard g++ toolchain
(identical to what worked before the xx refactor). The cross-compilation
path (amd64 → arm64) continues to use xx-clang / xx-clang++ / xx-ar with
the target sysroot populated via xx-apk.
Node.js v20 uses ARMv8.1 LSE atomic instructions that the GitHub Actions
QEMU version does not support, causing SIGILL / "Illegal instruction" core
dumps when npm ci runs inside the arm64 emulated layer.
Solution: add a dedicated prod-deps build stage that runs on $BUILDPLATFORM
(amd64, native) and uses tonistiigi/xx + xx-clang to cross-compile native
modules (bcrypt, better-sqlite3, node-pty) for the TARGET architecture
without ever executing Node.js under QEMU. The final runtime stage simply
copies the pre-compiled node_modules from prod-deps.
Stage breakdown:
xx - cross-compilation helper (provides xx-clang, xx-apk)
frontend-builder - $BUILDPLATFORM, vite build (no native modules)
backend-builder - $BUILDPLATFORM, tsc compilation
prod-deps - $BUILDPLATFORM + xx-clang cross-compiles for $TARGET
final - TARGET platform, no compilation at all
- Add docker/setup-qemu-action@v3 to docker-publish.yml (was missing,
causing multi-platform builds to hang indefinitely)
- Add platforms: linux/amd64,linux/arm64 to build-push-action step
- Optimize Dockerfile with --platform=$BUILDPLATFORM on builder stages
so TypeScript compilation runs at native amd64 speed; only the lean
npm ci --omit=dev step runs under QEMU in the final stage, compiling
the three native modules (bcrypt, better-sqlite3, node-pty) for the
correct target architecture — reduces arm64 build time from 6+ hours
to ~15-30 minutes
The merge of PR58 into PR59's branch produced a corrupt docker-entrypoint.sh
(97 lines) where PR59's if block was cut off mid-way (missing exec su-exec
and closing fi) and then PR58's entire if block was appended. This caused:
/usr/local/bin/docker-entrypoint.sh: line 98: syntax error: unexpected
end of file (expecting "fi")
Fix: overwrite with the clean 71-line version and add a defensive
`sed -i 's/\r//'` step in the Dockerfile to strip Windows CRLF line
endings at build time, preventing this class of error even if CRLF
slips past .gitattributes in future.
Add docker-entrypoint.sh that runs as root at startup, fixes ownership
of the DATA_DIR volume (only files with wrong user or group), then drops
to the non-root sencho user via su-exec before starting Node.
This eliminates the SQLITE_READONLY crash that occurs when a host-mounted
data volume was created by root or chowned to the wrong UID. The pattern
mirrors the official PostgreSQL, Redis, and MariaDB Docker images.
- Install su-exec in Stage 3 (10KB Alpine tool, idiomatic alternative to gosu)
- Remove USER directive; entrypoint handles the privilege drop instead
- Use ENTRYPOINT + CMD so Node becomes PID 1 (correct SIGTERM handling)
- Add .gitattributes to enforce LF line endings for *.sh files
SECURITY (critical fixes):
- Add authMiddleware to /api/system/console-token (was publicly accessible)
- Validate api_url on node create/update to prevent SSRF (rejects localhost/loopback)
- Add rate limiting (5 req/15 min/IP) to /api/auth/login and /api/auth/setup
- Fix path traversal in env_file resolution — absolute/escaping paths rejected
- Add stack name validation to GET routes (was only on PUT/POST)
- Add helmet security headers middleware
- Restrict CORS to FRONTEND_URL in production
PRODUCTION READINESS:
- Add GET /api/health public endpoint + HEALTHCHECK in Dockerfile
- Add SIGTERM/SIGINT graceful shutdown handler (drains connections, closes DB)
- Run container as non-root sencho user in Dockerfile
QUALITY:
- Fix 4 silent empty catch{} blocks in EditorLayout (now show toast.error)
- Connect ErrorBoundary to root App in main.tsx
- Replace WebSocket.Server with named WebSocketServer import (ESM compat)
TESTING (new automated test suite):
- Install Vitest; 38 backend tests across 4 suites covering validation utilities,
health endpoint, auth middleware, login flows, SSRF protection, and path traversal
- Extract isValidStackName/isValidRemoteUrl/isPathWithinBase to utils/validation.ts
- Playwright E2E scaffolding: auth, stacks, nodes specs + shared login helper
- CI: run Vitest + ESLint on every PR