Skip to content

Desktop: broker serves the built SPA (/ + /assets) -- FastAPI off the critical path - #80

Merged
zkann merged 1 commit into
mainfrom
desktop-broker-spa
Jun 18, 2026
Merged

Desktop: broker serves the built SPA (/ + /assets) -- FastAPI off the critical path#80
zkann merged 1 commit into
mainfrom
desktop-broker-spa

Conversation

@zkann

@zkann zkann commented Jun 18, 2026

Copy link
Copy Markdown
Owner

The broker now serves the dashboard page itself instead of forwarding it to FastAPI — the last piece of the switchover. Mirrors dashboard_app's index + assets routes:

  • GET / — token-gated (?t=); reads frontend/dist/index.html and injects window.__SMBOS_TOKEN__ before </head> (the server token, not the ?t= value); the friendly no-token 401 page byte-for-byte; 503 unbuilt / 500 no </head>.
  • GET /assets/<path> — the hashed, secret-free bundle (no token), path-contained against traversal, content-type by extension.

dist defaults to ../frontend/dist, or $SMBOS_DIST for a packaged app.

Milestone

With this, the broker owns the whole surface — the SPA, every read, the SSE live mirror, and all 11 actions. The only thing still forwarded to FastAPI is /api/settings (its echoed terminal is env-detected). FastAPI is off the critical path.

Verification

End-to-end against the real built SPA (/?t= → 200 with the token injected + the hashed bundle loads; no token → 401; encoded traversal + missing asset → 404) and a broker unit test (token gate, injection, asset content-type, traversal containment). 36 node.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features
    • Desktop application now serves its SPA directly with token-based access control for enhanced performance and security.
    • Static assets are served efficiently with built-in protection against path traversal and symlink-based escape attempts.

@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4d3cfe50-f56f-48b4-b93a-838265c8ce85

📥 Commits

Reviewing files that changed from the base of the PR and between fc72261 and 4642934.

📒 Files selected for processing (3)
  • desktop/broker.js
  • desktop/broker.test.js
  • desktop/spa.js

📝 Walkthrough

Walkthrough

Adds desktop/spa.js, a new module that serves the built frontend SPA with token-gated access to GET / (injecting window.__SMBOS_TOKEN__ into index.html) and path-traversal-protected static asset serving for GET /assets/*. The broker imports this module and routes those two paths before the existing SSE and FastAPI forwarding logic, with new integration tests covering all security cases.

Changes

SPA Static Serving

Layer / File(s) Summary
SPA module: token gate, asset serving, distDir
desktop/spa.js
New file exports distDir(), serveIndex(), and serveAsset(). Defines PAGE_HEADERS, NO_TOKEN_PAGE fallback HTML, and a MIME type map. tokenOk() uses crypto.timingSafeEqual. serveIndex() validates ?t= against the dashboard token, injects window.__SMBOS_TOKEN__ before </head>, and returns 401 on failure. serveAsset() applies both lexical containment and realpathSync symlink checks before serving files from dist/assets.
Broker integration: SPA import and route interception
desktop/broker.js
Imports ./spa and inserts two route branches inside createBroker before SSE/FastAPI forwarding: GET / calls spa.serveIndex when sopDir is set, and GET /assets/* calls spa.serveAsset with a URI-decoded path. Both return early; errors write 500 only if headers are not already sent.
Broker SPA integration tests
desktop/broker.test.js
New test block creates a temporary dist/assets tree with index.html, app.js, and an external secret.txt plus a symlink escape. Asserts: unauthenticated / → 401, authenticated / → 200 with injected token and cache-control: no-store, /assets/app.js → 200 with JS content-type, URL-encoded traversal → 404, symlink escape → 404.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Broker as createBroker handler
  participant spa as desktop/spa.js
  participant FS as dist/ filesystem

  Client->>Broker: GET /?t=<token>
  Broker->>spa: serveIndex(req, res, sopDir)
  spa->>spa: tokenOk(t, expected)
  alt token invalid or missing
    spa-->>Client: 401 NO_TOKEN_PAGE HTML
  else token valid
    spa->>FS: readFileSync index.html
    spa->>spa: inject window.__SMBOS_TOKEN__ before </head>
    spa-->>Client: 200 text/html + cache-control: no-store
  end

  Client->>Broker: GET /assets/app.js
  Broker->>spa: serveAsset(req, res, "app.js")
  spa->>spa: lexical containment check
  spa->>FS: realpathSync symlink check
  alt traversal / symlink escape / missing
    spa-->>Client: 404
  else safe path
    spa->>FS: readFileSync asset bytes
    spa-->>Client: 200 + content-type from MIME map
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • zkann/smbos#38: Fixes frontend/dashboard behavior to preserve the ?t= query parameter, which is the same token flow consumed by the new serveIndex() token gate.
  • zkann/smbos#69: Established the loopback reverse-proxy and request-handling structure inside createBroker that this PR extends with the new / and /assets/* SPA route branches.
  • zkann/smbos#70: Extends the same createBroker(..., sopDir) routing to serve /api/plate and /api/queue locally, using the same sopDir-gated broker flow that this PR uses for SPA serving.

Poem

🐇 Hops along the asset trail,
checking tokens without fail.
window.__SMBOS_TOKEN__ slipped in neat,
symlink sneaks get no treat.
no-store cached, the SPA glows —
a bunny guards each path it knows! 🌟

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly summarizes the main change: the broker now serves the built SPA with two new routes (/ and /assets) and removes FastAPI from the critical path, which is exactly what the changeset implements.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch desktop-broker-spa

Comment @coderabbitai help to get the list of available commands and usage tips.

… critical path

The broker now serves the dashboard page itself instead of forwarding it to FastAPI, the
last piece of the switchover. Mirrors dashboard_app's index + assets routes:
- GET /        token-gated (?t=); reads frontend/dist/index.html and injects
               window.__SMBOS_TOKEN__ before </head> (the server token, not the ?t= value);
               the friendly no-token 401 page byte-for-byte; 503 unbuilt / 500 no </head>.
- GET /assets/<path>  the hashed, secret-free bundle (no token), path-contained against
               traversal, content-type by extension.

dist defaults to ../frontend/dist, or  for a packaged app. With this, the broker
owns the WHOLE surface -- the SPA, every read, the SSE live mirror, and all 11 actions; the
only thing still forwarded to FastAPI is /api/settings (its echoed terminal is env-detected).

Verified end-to-end against the REAL built SPA (/?t= -> 200 with the token injected + the
hashed bundle loads; no token -> 401; encoded traversal + missing asset -> 404) and by a
broker unit test (token gate, injection, asset content-type, traversal containment). 36 node.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@zkann

zkann commented Jun 18, 2026

Copy link
Copy Markdown
Owner Author

Adversarial self-review hardening (folded into the latest push): the traversal containment + token gate were sound, but the adversarial pass found one real divergence -- the JS containment was WEAKER than the FastAPI original. path.resolve is purely lexical (it does not follow symlinks), whereas Python Path.resolve dereferences them, so a symlink inside assets/ pointing outside would pass the lexical startsWith check and readFileSync would follow it. Fixed: serveAsset now re-checks containment after fs.realpathSync (both target and base), matching Python -- a symlink that escapes the realpath base 404s. Added a test (a symlink in assets/ -> outside -> 404). The token-injection, the no-store on the token-bearing HTML, the 401-no-leak, and parity on the error pages were all confirmed clean.

@zkann
zkann force-pushed the desktop-broker-spa branch from 9966012 to 4642934 Compare June 18, 2026 20:33

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9966012473

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread desktop/spa.js Outdated
Comment on lines +72 to +73
const target = path.resolve(base, assetPath)
if (target !== base && !target.startsWith(base + path.sep)) { // containment: no ../ escape

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve symlinks before serving assets

If dist/assets contains a symlink (for example from a copied or packaged asset), this lexical path.resolve check passes while fs.statSync/readFileSync follow the link, so /assets/<link> can serve files outside the asset directory without a token. The FastAPI route this replaces resolved (base / path) before the containment check, which rejected symlink escapes; use realpath/equivalent for both base and target before reading.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already fixed in the latest push (our own adversarial pass flagged the same thing): serveAsset now re-checks containment after fs.realpathSync on BOTH base and target, so a symlink inside assets/ that escapes the realpath base 404s -- matching the FastAPI route which resolved the path (dereferencing symlinks) before its containment check. The lexical path.resolve check stays as a fast pre-reject; the realpath check closes the symlink-follow gap. A test was added (a symlink in assets/ -> outside -> 404).

@zkann
zkann merged commit 41bebfd into main Jun 18, 2026
7 checks passed
@zkann
zkann deleted the desktop-broker-spa branch June 18, 2026 20:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant