Commit Graph

190 Commits

Author SHA1 Message Date
Anso 16f55bbb40 Merge pull request #54 from AnsoCode/fix/alerts-notifications-overhaul
feat(notifications): aggregate alerts from all connected nodes in the notification panel
2026-03-21 17:50:30 -04:00
SaelixCode 94d6c8fc0f fix(ts): use type-only import for Node to satisfy verbatimModuleSyntax 2026-03-21 17:48:31 -04:00
SaelixCode 1690f0d3c6 feat(notifications): aggregate alerts from all nodes in the notification panel
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
2026-03-21 17:38:55 -04:00
Anso 6c518cee5a Merge pull request #53 from AnsoCode/fix/alerts-notifications-overhaul
fix(ws): fix remote node console — delegate console session tokens
2026-03-21 17:18:38 -04:00
SaelixCode 30fe77cd5d fix(ws): fix remote node console by delegating console session tokens
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.
2026-03-21 16:53:46 -04:00
Anso 33c4976dc7 Merge pull request #52 from AnsoCode/fix/alerts-notifications-overhaul
fix(alerts): overhaul alerts & notifications system for local and remote nodes
2026-03-21 12:18:17 -04:00
SaelixCode ed6954307b fix(proxy): skip express.json() for remote proxy requests to fix body forwarding
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.
2026-03-21 11:56:24 -04:00
SaelixCode a703707aa0 fix(proxy): re-stream express.json()-consumed body to remote nodes for POST/PUT/PATCH
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.
2026-03-21 01:26:12 -04:00
SaelixCode e190f3ad8a fix(alerts): overhaul alerts & notifications system for local and remote nodes
- 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
2026-03-21 01:19:29 -04:00
Anso bfef6a7979 Merge pull request #51 from AnsoCode/security/terminal-hardening
security: harden terminal WebSocket endpoints (scope check, path traversal, env strip)
2026-03-21 00:27:22 -04:00
SaelixCode 2e0f3e2711 security: harden terminal WebSocket endpoints against three attack vectors
- 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
2026-03-21 00:12:16 -04:00
Anso c34092f8ec Merge pull request #50 from AnsoCode/feature/animated-design-system
feat(design): animated design system foundation with animate-ui and motion
2026-03-20 23:55:46 -04:00
SaelixCode 22e646286e fix(ui): resolve 9 animated design system bugs including Monaco tab height accumulation
- fix(editor): Monaco compose↔.env tab switching no longer accumulates height
  each switch — root cause was Monaco's position:static internal child creating
  a circular CSS dependency with the CSS grid; broken by calling layout({0,0})
  to force reflow before re-measuring. Added overflow-hidden to Monaco container.
- fix(ui): sliding tab indicator on compose/env tabs via motion.div layoutId
- fix(ui): sliding tab indicator on Notification tabs (Discord/Slack/Webhook)
- fix(ui): switch thumb now animates position (Radix data-attribute CSS translation)
- fix(ui): 'Always Local' tooltip no longer crashes — replaced animate-ui tooltip
  (getStrictContext throws outside provider) with pure Radix primitives + CSS animation
- feat(settings): Auto theme option added (light/dark/auto with matchMedia listener)
- fix(ui): NodeManager dialog buttons no longer stuck together (DialogFooter gap)
- fix(ui): AlertDialog spring animation + Quick Clean button text wraps correctly
- fix(ui): Resources/App Store/Logs buttons toggle off on second click
2026-03-20 23:51:54 -04:00
SaelixCode 0cb5fae947 feat(design): animated design system foundation with animate-ui and motion
Install motion + animate-ui, overhaul design tokens with brand cyan accent,
and replace CSS keyframe animations in Dialog, Tabs, Switch, and Tooltip
with spring-physics and blur-fade transitions via animate-ui Radix primitives.
2026-03-20 22:25:29 -04:00
Anso 4d1aef744b Merge pull request #49 from AnsoCode/feature/ws-notification-push
feat(notifications): replace polling with WebSocket push
2026-03-20 21:04:26 -04:00
SaelixCode a5ac3e4981 feat(notifications): replace polling with WebSocket push
Eliminates the 5-second setInterval polling loop for notifications in
EditorLayout. A persistent /ws/notifications WebSocket connection is
opened on mount; the backend pushes each alert the instant dispatchAlert
fires, with 5s auto-reconnect on close.

Key changes:
- NotificationService: injectable broadcaster callback (setBroadcaster)
- DatabaseService.addNotificationHistory: returns full NotificationHistory
  record (with id/is_read) instead of void
- index.ts: notificationSubscribers Set + /ws/notifications upgrade handler
  (JWT-verified, placed before remote proxy path)
- EditorLayout: polling removed, WS connect/reconnect replaces it
2026-03-20 20:47:51 -04:00
Anso 23a22598ab Merge pull request #48 from AnsoCode/feature/settings-hub
feat(settings): harden settings API and overhaul SettingsModal
2026-03-20 20:20:59 -04:00
SaelixCode ed0817b2c5 fix(settings): prevent X button overlap and add tooltip to Always Local badge 2026-03-20 20:16:52 -04:00
SaelixCode f7e8e40915 feat(settings): scope split — developer settings always target local node
- Add localOnly option to apiFetch — omits x-node-id header so the
  request bypasses the proxy and always hits the local Sencho instance
- fetchSettings now performs two fetches when a remote node is active:
  active node fetch for per-node settings (CPU/RAM/disk limits, janitor,
  crash detection), and a localOnly fetch for UI preferences
  (developer_mode, global_logs_refresh, metrics_retention_hours,
  log_retention_days)
- saveDeveloperSettings passes localOnly: true — developer preferences
  can no longer be written into a remote node's database
- Update scope badges: System Limits shows "Configuring: [node]",
  Developer shows "Always Local" when a remote node is active
2026-03-20 20:12:00 -04:00
SaelixCode 322e717514 feat(settings): harden settings API and overhaul SettingsModal
Security:
- Strip auth credential keys (auth_username, auth_password_hash,
  auth_jwt_secret) from GET /api/settings response
- Add allowlist guard to POST /api/settings — rejects unknown or
  auth-namespace keys with a 400

Backend:
- Add PATCH /api/settings bulk endpoint with Zod schema validation
  (type coercion, range checks, URL format) and atomic SQLite transaction
- Add system_state table — moves last_janitor_alert_timestamp out of
  global_settings; adds getSystemState/setSystemState on DatabaseService
- Add metrics_retention_hours and log_retention_days configurable settings;
  MonitorService reads both dynamically each evaluation cycle
- Add cleanupOldNotifications(days) to DatabaseService, called each cycle

Frontend:
- Replace single isLoading flag with per-operation states
  (isSavingSystem, isSavingDeveloper, isSavingPassword, isSavingRegistry,
  isSavingAgent/isTestingAgent per agent type)
- Add skeleton loader that blocks interaction until fetchSettings resolves
- Explicit key-picking in fetchSettings — auth keys cannot enter state
- Unsaved-changes amber dot on System Limits and Developer sidebar items
- Separate saveSystemSettings / saveDeveloperSettings — no cross-tab clobber
- Developer tab gains Data Retention section (metrics hours, log days)
- All settings saves use new PATCH /api/settings endpoint
2026-03-20 19:57:34 -04:00
Anso ae4540bf46 Merge pull request #47 from AnsoCode/feature/app-store-categories
feat(app-store): category filter bar + custom registry settings
2026-03-20 15:51:52 -04:00
Anso 5c62a4a630 Merge branch 'develop' into feature/app-store-categories 2026-03-20 15:51:28 -04:00
SaelixCode 34cad76d45 feat(app-store): category filter bar and custom registry settings
- TemplateService: static LSIO_CATEGORY_MAP covers ~80 well-known apps
  across Automation, Downloaders, Media, Monitoring, Networking,
  Security, Development, Productivity, Utilities, and Other. Category
  lookup is O(1) and runs once at cache-fill time. Adds source field
  ('linuxserver' | 'custom') to Template for future per-source UX.
  Portainer v2 registries pass their native categories through unchanged.
  Adds clearCache() method wired to POST /api/templates/refresh-cache.

- AppStoreView: category pill bar (All + sorted dynamic categories)
  rendered between search and grid; active pill fills on click; clicking
  a category badge on a card also sets the active filter; app count
  updates reactively; filter composed with existing search query.

- SettingsModal: new App Store section (local-node only) exposes a
  custom Portainer v2 JSON URL input with Save & Refresh (saves setting
  then busts the 24h template cache) and Reset to Default.
2026-03-20 15:19:32 -04:00
Anso ec3a2495a2 Merge pull request #46 from AnsoCode/fix/logs-view-oom-virtualization
fix(logs): cap DOM rendering to 300 rows to prevent browser OOM crash
2026-03-20 12:15:12 -04:00
SaelixCode 74964b0e26 fix(stats): throttle container stat WebSocket updates via ref buffer
Each container's onmessage handler was calling setContainerStats()
independently, causing React to schedule up to N separate reconciliation
passes per second (one per container). With 20 containers streaming Docker
stats at ~1 update/s, EditorLayout was re-rendering up to 20 times/s.

Fix: incoming stats are written into pendingStatsRef (no re-render cost),
then flushed to React state in one batched setContainerStats call every 1.5s.

Two bugs in the original proposal are addressed:
- rawBytesRef (never cleared) owns rx/tx tracking so net I/O rate is always
  accurate; avoids the stale containerStats closure that would have shown
  0 B/s after every flush cycle
- pending snapshot is captured and cleared BEFORE calling setState so the
  functional updater stays pure (no side-effects inside it)
- pendingStatsRef is cleared in the effect cleanup so stale entries from
  the previous stack don't briefly appear on stack switch
2026-03-20 12:14:09 -04:00
Anso 36cc14bac7 Merge branch 'develop' into fix/logs-view-oom-virtualization 2026-03-20 12:08:01 -04:00
Anso 9367abf8d3 Merge pull request #45 from AnsoCode/fix/create-stack-error-handling
fix(dashboard): surface server error messages in create-stack flow
2026-03-20 12:07:08 -04:00
SaelixCode 753b0c3539 fix(logs): use monotonic _id key to prevent O(n) DOM mutations on scroll
key={idx} with a shifting .slice(-300) window meant every new log arrival
caused React to update all 300 existing DOM nodes (index 1 became 0, 2
became 1, etc.), even though only one entry actually changed.

The proposed content-hash fix (timestamp + containerName + message.substring)
carries a real collision risk: chatty containers emitting identical lines at
the same millisecond produce duplicate keys, triggering undefined React
reconciliation behaviour.

Fix: add a _id: number field to LogEntry, stamped client-side with a
monotonic counter (logIdRef) at the point of ingestion — once in the SSE
onmessage handler and once in the polling setLogs path. React now tracks
data identity rather than position, so the slice window can shift without
touching the 299 unchanged DOM nodes.
2026-03-20 12:06:36 -04:00
SaelixCode 0db6c946e7 fix(logs): cap DOM rendering to 300 rows to prevent OOM crash
Playwright investigation revealed the Logs view (GlobalObservabilityView)
rendered 1,859+ log entries as real DOM nodes (9,616 total DOM nodes) with
no virtualization. Combined with a 5-second polling cycle replacing all
React elements each time and smooth-scroll animations stacking on every
update, the renderer process grew rapidly on a host running at 97% RAM
usage, crashing the browser tab with Out of Memory within minutes.

Fixes:
- Cap rendered DOM rows to MAX_DISPLAY_ROWS (300) via .slice(-300) so the
  browser only ever holds ~1,500 log-related DOM nodes regardless of how
  many entries are in state
- Add a truncation notice when log count exceeds the display cap
- Reduce SSE-mode in-memory log cap from 10,000 to MAX_LOG_ENTRIES (2,000)
- Switch auto-scroll from behavior:'smooth' to behavior:'instant' to stop
  stacking layout animations on every 5-second poll
- Reduce /api/logs/global response limit from 2,000 to 500 lines since the
  client renders at most 300 rows, making the extra payload wasteful
2026-03-20 11:59:24 -04:00
SaelixCode 3b2634f9dd fix(dashboard): surface server error messages in create-stack flow
- Read JSON error body from non-ok responses before throwing, so
  status-specific messages (409 already exists, 400 invalid name) reach
  the user instead of generic hardcoded strings
- Apply defensive toast pattern: error?.message || error?.error || fallback
2026-03-20 09:57:01 -04:00
Anso 18314119b0 Merge pull request #44 from AnsoCode/fix/remote-node-hardening
fix(remote): harden WS stream lifecycle, auth precedence, and proxy error handling
2026-03-20 09:17:32 -04:00
SaelixCode abefd5e1f6 fix(remote): harden WS stream lifecycle, auth precedence, and proxy error handling
- Destroy Docker stats stream on WS close to prevent orphaned daemon polling
- Guard all ws.send() calls with readyState === OPEN check
- Add .catch() to unawaited streamStats/execContainer calls to prevent
  unhandled rejections crashing the process (Node >= 15)
- Close per-connection WebSocket.Server instances after handleUpgrade to
  prevent listener accumulation over many connections
- Invert auth token precedence to bearerToken || cookieToken in both
  authMiddleware and the WS upgrade handler so node-to-node Bearer tokens
  are never shadowed by a stale browser cookie
- Narrow proxyRes type in remoteNodeProxy error handler before calling
  .status() to avoid throwing on raw Socket (WS/TCP-level proxy errors)
2026-03-20 08:47:39 -04:00
SaelixCode 43436c15c1 Merge remote-tracking branch 'origin/main' into develop 2026-03-20 08:45:42 -04:00
Anso f115a48cfe Merge pull request #43 from AnsoCode/fix/remote-ws-stats-exec-openapp
fix(remote): strip cookie & nodeId from WS/HTTP proxy to remote nodes
2026-03-20 00:51:08 -04:00
SaelixCode 774190cbb8 fix(remote): strip cookie header and nodeId from WS/HTTP proxy to remote nodes
Two related auth/routing bugs broke Terminal logs, container stats, the
LogViewer SSE stream, and exec bash when a remote node was selected:

1. WebSocket proxy (upgrade handler) was forwarding the browser's
   `cookie` header to the remote Sencho instance.  The remote's
   `authMiddleware` evaluates `cookieToken || bearerToken`, so it
   picked the cookie first — signed with the *local* JWT secret — and
   returned 401 before even seeing the valid Bearer token.
   Fix: `delete req.headers['cookie']` before `wsProxyServer.ws()`,
   matching the `proxyReq.removeHeader('cookie')` already present in
   the HTTP proxy.

2. WebSocket proxy was also forwarding `?nodeId=<gatewayId>` in the
   URL.  The remote's `nodeContextMiddleware` rejected it with 404
   ("Node X not found") because gateway node IDs don't exist on the
   remote instance.
   Fix: strip `nodeId` from `req.url` before proxying, so the remote
   defaults cleanly to its own local node.

3. HTTP proxy (`remoteNodeProxy`) was forwarding `?nodeId=<gatewayId>`
   in `proxyReq.path` to the remote.  Affected EventSource endpoints
   like `/api/containers/:id/logs?nodeId=9` that pass nodeId as a
   query param rather than the `x-node-id` header.
   Fix: strip `nodeId` from `proxyReq.path` in `onProxyReq`.
2026-03-20 00:33:48 -04:00
SaelixCode 2ba44acde1 chore: ignore local Claude Code, Playwright MCP, and plans files 2026-03-19 23:16:17 -04:00
Anso dbf8ec8a2a Merge pull request #42 from AnsoCode/fix/remote-ws-stats-exec-openapp
fix(remote): repair stats, bash exec, and Open App for remote nodes
2026-03-19 22:48:23 -04:00
SaelixCode 94018d167f fix(remote): repair stats, bash exec, and Open App for remote nodes
- Generic WebSockets (stats, exec) now connect to /ws?nodeId= instead of
  the bare root so the upgrade handler detects the remote node and proxies
  the WS connection to the correct remote Sencho instance.
- Vite dev proxy gains ws:true on /api and a new /ws entry so WebSocket
  upgrades reach localhost:3000 during npm run dev.
- Backend WS message handler falls back to the default local node when the
  nodeId in a proxied message doesn't exist in the local DB.
- Open App button now extracts the hostname from the remote node's api_url
  instead of using window.location.hostname.
2026-03-19 22:45:00 -04:00
Anso a467c2faea Merge pull request #41 from AnsoCode/feature/fix-remote-proxy-order
Fix Remote Node System Stats: Remove Broken getDocker() Branch
2026-03-19 22:08:16 -04:00
SaelixCode 3f473c5c97 fix(backend): remove broken remote branch in /api/system/stats
The remoteNodeProxy middleware (line 373) is already positioned before
all API route definitions, so remote requests are correctly proxied to
the target Sencho instance before any route handler executes.

However, /api/system/stats had a dead remote branch that called
NodeRegistry.getDocker() for remote nodes. This method throws by design
("remote nodes are not directly accessible") since no direct Docker TCP
socket is used for remote nodes. The thrown error propagated through the
catch block, returning a 500 to the frontend and causing system stats to
show as loading/unavailable for remote nodes.

Removed the dead branch. Remote requests for /api/system/stats are now
correctly proxied, returning real CPU/RAM/disk data from the remote host.
2026-03-19 22:02:29 -04:00
Anso ef5621e484 Merge pull request #40 from AnsoCode/feature/image-update-checker
feat(system): background image update checker with stack badges
2026-03-19 21:37:30 -04:00
SaelixCode d64d23fc50 feat(system): background image update checker with stack badges
Adds an ImageUpdateService that queries OCI registry manifest endpoints
every 6 hours to compare remote digests against local RepoDigests.
Results are cached in a new stack_update_status SQLite table. A pulsing
blue dot badge appears in the stack sidebar for stacks with updates
available. Manual refresh available via POST /api/image-updates/refresh
with a 10-minute rate limit.
2026-03-19 21:28:48 -04:00
Anso b7748b4d17 Merge pull request #39 from AnsoCode/feature/remote-context
feat(ui): Phase 57 - Remote Context Navigation
2026-03-19 20:45:34 -04:00
SaelixCode 04c770c198 feat(ui): Phase 57 - remote context UX (Option A) + network layer fixes
Task 1 - Network layer fixes:
- AppStoreView: replace raw fetch() with apiFetch() on /templates and
  /templates/deploy so x-node-id header is injected for proxy routing
- GlobalObservabilityView: replace raw fetch() with apiFetch() on
  /stacks and /logs/global
- HostConsole: append ?nodeId= to WebSocket URL so the upgrade handler
  correctly routes the PTY session to the active remote node

Task 2 - Scoped context two-tier UX (Option A / Portainer-style):
- EditorLayout: add a context pill in the top header bar showing the
  active node name (pulsing blue for remote, green for local)
- HostConsole: header now reads "Host Console - [Node Name]"
- ResourcesView: title now reads "Resources Hub - [Node Name]" for remote
- GlobalObservabilityView: floating node badge in top-left corner for remote
- AppStoreView: deploy sheet shows "Deploying to: [Node Name]" badge
- SettingsModal: remote nodes only see System Limits + Developer tabs;
  global-only tabs (Account, Appearance, Notifications, Nodes) are hidden;
  header subtitle shows remote node name
2026-03-19 20:39:57 -04:00
Anso e027a94492 Merge pull request #38 from AnsoCode/fix/dashboard-oom-leak
perf: fix dashboard out of memory crash on remote nodes
2026-03-19 20:04:37 -04:00
SaelixCode 4e9777d47f perf: fix dashboard out of memory crashing from massive historical metrics payloads 2026-03-19 19:59:40 -04:00
Anso eb58f302a1 Merge pull request #37 from AnsoCode/fix/node-context-hydration
fix(frontend): sync NodeContext with localStorage on initial load
2026-03-19 19:23:13 -04:00
SaelixCode f400959a4b fix(frontend): sync NodeContext with localStorage on initial load 2026-03-19 18:36:27 -04:00
Anso 39e63bea8e Merge pull request #36 from AnsoCode/fix/remote-proxy-cookie-strip-and-middleware-loop
fix: strip browser cookie from proxy requests; fix nodeContextMiddleware self-heal loop
2026-03-19 18:14:27 -04:00
SaelixCode 7b2f28f505 fix: proxy forwards browser cookie to remote causing 401; fix nodeContextMiddleware loop
Two backend fixes:

1. proxyReq was forwarding the browser's sencho_token cookie to the remote
   Sencho. The remote's authMiddleware uses cookieToken || bearerToken, so
   the invalid local-signed cookie was verified before the valid Bearer token,
   returning 401 on every proxied API call. Strip the cookie header in
   proxyReq so remote auth uses only the Bearer token.

2. nodeContextMiddleware blocked /api/nodes when x-node-id referenced a
   deleted node, trapping the frontend in an unrecoverable 404 loop (it
   could not fetch the nodes list to discover the node was gone). Exempt
   /api/nodes alongside /api/auth/ so the app can always self-heal.
2026-03-19 18:10:48 -04:00