- Add sync-docs CI job: runs on push to main, copies /docs into sencho-docs repo via DOCS_REPO_TOKEN
- Scaffold /docs with mint.json, getting-started/introduction.mdx, getting-started/quickstart.mdx, features/overview.mdx
- Update CHANGELOG
Two console errors on HTTP deployments with no functional impact:
1. Helmet's default Cross-Origin-Opener-Policy: same-origin is ignored
by browsers over HTTP but logged as a console error. Disabled via
crossOriginOpenerPolicy: false (same rationale as HSTS/COEP).
2. Vite's production build injects an inline module-preload polyfill
script blocked by script-src 'self'. Disabled via
build.modulePreload.polyfill: false — all modern browsers support
link rel="modulepreload" natively.
Containers launched from the COMPOSE_DIR root (not individual subdirs)
all share the root folder's name as their com.docker.compose.project
label, making them appear as "external" despite being in COMPOSE_DIR.
Switch to com.docker.compose.project.working_dir: a container is
managed if its working directory is within COMPOSE_DIR, regardless
of what project name Docker Compose assigned to it.
Helmet 8 merges custom directives with its built-in defaults, which
include upgrade-insecure-requests. Simply omitting the directive from
the custom object (PR #59) was insufficient — Helmet silently re-adds
it from defaults. Setting upgradeInsecureRequests: null is the correct
Helmet 8 API to remove a default directive.
This was the root cause of the persistent blank page on plain-HTTP
self-hosted deployments: the directive tells browsers to upgrade all
HTTP sub-resource fetches to HTTPS, producing ERR_SSL_PROTOCOL_ERROR
on every JS/CSS asset.
- Add x-sencho-proxy sentinel header to all proxied responses so
the frontend can distinguish remote auth failures from local session
expiry, breaking the logout loop when a node's api_token expires
- Add authMiddleware to all 5 /api/notifications endpoints that were
missing protection (default-deny policy enforcement)
- Expand CSP to include connectSrc ws:/wss: and workerSrc blob:
for WebSocket and Monaco editor worker support
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.
Helmet's defaults are designed for HTTPS-only deployments. Two directives
were actively breaking plain-HTTP self-hosted instances:
- upgrade-insecure-requests: causes browsers to upgrade all JS/CSS/asset
fetches to HTTPS → ERR_SSL_PROTOCOL_ERROR → completely blank page
- Strict-Transport-Security: permanently instructs browsers to refuse HTTP
for 1 year, even after the header is removed from the server
Also handle docker socket GID=0 (root:root) edge case in entrypoint and
add [entrypoint] diagnostic log lines for easier debugging.
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
- Classify all Docker images, volumes, and networks as managed (Sencho
stack), external (other Compose project), or unused/system via a new
getClassifiedResources() method and GET /api/system/resources endpoint
- Add pruneManagedOnly() + getDiskUsageClassified() to DockerController
- Prune buttons now default to Sencho-managed scope; "All Docker" is
hidden in a ⋮ dropdown with a distinct destructive confirm dialog
- Replace Reclaimable Space donut with interactive Docker Disk Footprint
widget (stacked bar with clickable segments that filter resource tabs)
- Add managed/external filter toggles and classification badges per tab
- GET /api/stats now returns managed + unmanaged container counts; Home
Dashboard Active Containers card subtitle shows "N managed · N external"
- Rename "Ghost Containers" tab/copy to "Unmanaged Containers" throughout
The Add Node button requires both api_url AND api_token to be non-empty
before it enables. Both validation tests were only filling api_url,
leaving the button permanently disabled and timing out after 30s.
Add a dummy api_token fill in each test so the button enables and the
form submits — the backend then correctly rejects the invalid URL.
- openAddNodeAsRemote helper: clicks #node-type (Radix combobox, not native select),
picks the 'Remote' option, then waits for #node-api-url to appear — the API URL
field is conditionally rendered only when type === 'remote'
- Fix getByLabel(/node name/i) → #node-name in both tests (missed in second test)
- Use .last() for submit button to avoid matching the Add Node trigger behind the dialog
- Remove typeSelect.selectOption() which throws 'not a select element' on Radix UI
The NodeManager form labels the field 'Name' (not 'Node Name'), so
getByLabel(/node name/i) never matched and timed out after 30s.
Switch to the stable #node-name id which is bound via htmlFor.
stacks.spec.ts:
- waitForStacksLoaded: wait for 'Create Stack' button instead of [cmdk-item] > 0
CI starts with empty COMPOSE_DIR so there are no stacks; the button is always
rendered in the sidebar regardless of whether any stacks exist
nodes.spec.ts:
- beforeEach: click Settings then 'Nodes' nav item to reach NodeManager
The Add Node button lives inside Settings → Nodes; the previous beforeEach
only clicked Settings but never navigated to the Nodes section, so the
add-node button was never found and tests silently skipped
- Split serial build-and-test job into parallel backend, frontend, docker-validate, and e2e jobs
- backend job: build → test → lint (new ESLint) → npm audit --audit-level=high
- frontend job: build → lint → npm audit --audit-level=high
- docker-validate job: builds image on every PR without pushing; Trivy scans for CRITICAL/HIGH CVEs (informational)
- e2e job: runs full Playwright suite against live dev servers after backend+frontend pass
- Add backend/eslint.config.mjs and lint script to backend/package.json
- Replace catch (error: any) with catch (error) + (error as Error).message cast
in EditorLayout, NodeManager, HomeDashboard, AppStoreView
- Define TemplateVolume interface in AppStoreView; replace volumes any[] with typed array
- Define MetricPoint interface in HomeDashboard; replace metrics any[] with MetricPoint[]
- Define TerminalContainer type in BashExecModal; replace as any DOM property casts
- Define NodeTestInfo interface in NodeManager; replace info: any with typed shape
- Fix DockerNetworkStats cast in EditorLayout container stats WebSocket handler
- Remove unused catch variable (e) in api.ts and other components
- Cast streamFilter onValueChange val to union type in GlobalObservabilityView
- Add eslint-disable-next-line react-refresh/only-export-components to badge.tsx,
button.tsx, AuthContext, NodeContext, use-data-state, use-is-in-view
- Add eslint-disable-next-line react-hooks/set-state-in-effect in LogViewer
- Add /* eslint-disable */ to animate-ui third-party primitive files
- Fix rate limiter to allow 100 attempts in dev mode so E2E tests are
not blocked by failed-login attempts during test development
- Simplify logout button selector to use Lucide icon class (lucide-log-out)
instead of the fragile Tooltip-content locator chain that broke on navigation
- Rewrite stacks E2E spec: use waitForFunction to wait for sidebar to load,
delete leftover stacks via browser-context fetch before creating, and use
page.reload() to get a clean sidebar state
- Fix AlertDialogContent: remove asChild+motion.div pattern that triggered a
React.Children.only crash — Radix AlertDialog.Content injects a second
DescriptionWarning child internally, breaking Slot when asChild=true; replace
with CSS keyframe animations (data-[state=open]:animate-in)
- Fix final assertion in delete test to use exact text + listbox scope to avoid
false positives from similarly-named stacks like e2e-test-stack-*
- All 6 E2E tests pass (4 auth + 2 stacks); node tests skip gracefully
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
Remote-node alerts now appear in the local notification bell alongside
local ones, each tagged with the originating node name.
- backend: reorder WS upgrade handler so /ws/notifications?nodeId=<remote>
falls through to the existing proxy path instead of short-circuiting
- frontend/api.ts: add fetchForNode() helper for explicit node-targeted
requests without touching the localStorage active-node key
- frontend/EditorLayout: fetch notification history from all registered
nodes in parallel on mount and on node-list changes; open a per-remote
WebSocket connection for real-time push; route mark-read / delete / clear
actions back to the originating node; show node-name badge on remote alerts
When the gateway proxied a WebSocket upgrade for a remote node's interactive
terminal (host console or container exec), it injected the long-lived api_token
(scope: 'node_proxy') as the Bearer token. The remote's WS upgrade handler
correctly blocked this token via its isProxyToken guard (added in a prior
security hardening commit), causing a 403 → socket destroy → "connection error,
session ended" on the client.
Fix: introduce a POST /api/system/console-token endpoint that issues a
short-lived JWT (scope: 'console_session', 60 s TTL). Before forwarding an
interactive WS upgrade to a remote node, the gateway calls this endpoint using
the api_token, then substitutes the returned console_session token as the Bearer
header for the WS upgrade. The remote's isProxyToken guard (scope === 'node_proxy')
continues to block the long-lived api_token from spawning shells directly while
allowing gateway-delegated console sessions through.
Non-interactive WS paths (stack logs) continue to use the api_token unchanged.
The previous fix attempted to re-stream req.body via proxyReq.write() in the
proxyReq event handler. This raced against http-proxy's synchronous
req.pipe(proxyReq) call: when express.json() consumed the IncomingMessage stream,
Node.js's pipe() detected readableEnded and scheduled process.nextTick(proxyReq.end),
which fired before the proxyReq socket event. Any subsequent write() threw
"write after end", leaving the remote server stalled waiting for body bytes that
never arrived (the "Add Rule spins forever" bug).
Root fix: a conditional middleware now skips express.json() entirely for requests
targeting a remote node on non-auth, non-nodes API paths. The raw stream is left
intact, allowing http-proxy's req.pipe(proxyReq) to forward the body correctly.
express.json() fully drains the incoming request stream. http-proxy-middleware then
pipes an already-consumed empty stream to the remote Sencho instance, which holds the
connection open waiting for body bytes that never arrive (Content-Length says N but 0
bytes are forwarded). This caused all POST/PUT/PATCH requests to remote nodes — most
visibly 'Add Alert Rule' — to hang indefinitely with an infinite loading spinner.
Fix: in the proxyReq handler, write JSON.stringify(req.body) with correct Content-Type
and Content-Length headers before forwarding, restoring the full request body.
- StackAlertSheet: fetch agent status on open and show contextual banner
(amber warning when no channels configured, green when active, blue info
callout on remote nodes explaining remote-instance evaluation semantics)
- StackAlertSheet: surface actual server error message on addAlert failure
instead of generic string; add console logging for all failure paths
- SettingsModal: expose Notifications tab for remote nodes so agents can be
configured directly on any Sencho instance; section header shows node name
and Remote badge with tooltip when proxying to a remote node
- SettingsModal: re-fetch agents/settings when active node changes
- SettingsModal: add hidden DialogTitle/DialogDescription to satisfy Radix
UI accessibility requirements (eliminates console errors)
- backend POST /api/alerts: add Zod validation schema; rejects unknown
metric/operator values, negative thresholds, and missing fields with 400
- EditorLayout WS: upgrade reconnect from flat 5s retry to exponential
backoff (1s→2s→4s→8s→16s→30s max); onerror now logs the event; cleanup
only closes OPEN sockets — CONNECTING sockets are closed in onopen after
isMounted check, eliminating "closed before established" StrictMode error
- Reject node_proxy scoped JWT tokens with 403 on host-console and
container exec (/ws) upgrades; machine-to-machine credentials must
not open interactive shells
- Validate stackParam against path.resolve + startsWith(baseDir) to
prevent directory traversal on the PTY cwd (Rule 9 pattern)
- Strip JWT_SECRET, AUTH_PASSWORD, AUTH_PASSWORD_HASH, DATABASE_URL
from the environment passed to node-pty spawned shells