fix(dashboard): exempt SPA shell from bearer-token auth

The bearer-token middleware was mounted globally on the Express app, so
the initial GET /?token=<token> request for index.html was rejected with
401 before the frontend JS could even load and capture the token from
the URL. The server-side query-string fallback only accepts fn_token=,
while the banner URL uses ?token= (which only the frontend captures), so
the bootstrap request had no valid auth path.

Fix: gate auth on /api/* only. The SPA shell (index.html, /assets/*,
favicon) is public — it contains no secrets, and the frontend auth.ts
module captures the token into localStorage and installs a fetch wrapper
that injects Authorization: Bearer on every /api/* call.

Also: add .env / .env.local to .gitignore so local FUSION_DAEMON_TOKEN
values don't accidentally get committed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-21 20:44:13 -07:00
parent fc72197b83
commit d704e24244
3 changed files with 29 additions and 3 deletions

View File

@@ -19,6 +19,17 @@ export const TOKEN_QUERY_PARAM = "fn_token";
/** Paths that are exempt from authentication (liveness probes). */
const EXEMPT_PATHS = ["/api/health"];
/**
* Only /api/* paths are gated by this middleware. The SPA shell (index.html,
* /assets/*, favicon, etc.) must load unauthenticated so the frontend JS can
* run, read ?token= off the URL, and start injecting Bearer headers on API
* calls. Without this exemption the browser gets 401 on the very first GET /
* and never gets a chance to capture the token.
*/
function isApiPath(path: string): boolean {
return path === "/api" || path.startsWith("/api/");
}
/**
* Check if daemon auth should be active.
* Auth is enabled when FUSION_DAEMON_TOKEN env var is set OR daemon options are provided.
@@ -133,7 +144,13 @@ export function createAuthMiddleware(token: string) {
};
return function authMiddleware(req: Request, res: Response, next: NextFunction): void {
// Always allow exempt paths
// The SPA shell and static assets are public — only /api/* is gated.
if (!isApiPath(req.path)) {
next();
return;
}
// Always allow exempt paths (liveness probes)
if (isExemptPath(req.path)) {
next();
return;

View File

@@ -392,8 +392,12 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
}));
// Daemon mode: bearer token authentication middleware
// Auth is enabled when daemon option is provided OR FUSION_DAEMON_TOKEN env var is set
// The middleware itself exempts /api/health for liveness probes
// Auth is enabled when daemon option is provided OR FUSION_DAEMON_TOKEN env var is set.
// The middleware exempts /api/health and everything outside /api/ — the SPA shell
// (index.html + built assets) is public so the browser can load the frontend JS
// that then captures ?token= from the URL and injects a Bearer header on every
// /api/* call. WebSocket upgrades are gated separately in setupTerminalWebSocket /
// setupBadgeWebSocket.
const daemonToken = options?.daemon?.token ?? process.env.FUSION_DAEMON_TOKEN;
if (daemonToken) {
app.use(createAuthMiddleware(daemonToken));