feat(admin): scaffold the embedded admin SPA behind --features ui - #503
Conversation
Step 3 of the admin UI track (ADR-0003): the SPA's own package and
lockfile, the `--features ui` gate, asset embedding, and an SPA fallback
confined to the `/admin` mount. Porting the existing views off the Rust
string literals is step 4, so `GET /admin` still serves the
server-rendered dashboard either way.
- `ui/`: React + Vite + TypeScript package with its own npm lockfile,
separate from `site/`'s Astro toolchain (Resolution 2). `base: '/admin/'`
makes the emitted asset URLs `/admin/assets/...`.
- `--features ui` embeds `ui/dist` with rust-embed and types each asset
with mime_guess. A default `cargo build` needs no Node toolchain and
carries no bundle (Resolution 1); release CI enables the feature.
- Three catch-alls inside the mount: `/admin/assets/{*path}` serves the
bundle with `X-Content-Type-Options: nosniff`, `/admin/{*path}` serves
the shell so a deep link survives a reload, and `/admin/api/{*path}`
answers `404` in the gateway error shape for every method — `/admin/api`
is a JSON namespace, and answering it with HTML is the failure
"Why not the root" rules out. An unmatched path outside the mount is
unaffected.
- `tests/router_surface.rs` stays a strict inventory and is now
cfg-aware: the source scans see the three literals unconditionally,
while the runtime probes and the legacy-path assertion switch on the
feature.
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
There was a problem hiding this comment.
Code Review
This pull request introduces an embedded admin SPA bundle (built with React, Vite, and TypeScript) into the binary under the --features ui flag, along with corresponding routing, documentation, and integration tests. A security review of the changes identified that the SPA HTML shell response in src/admin/ui.rs is missing mandatory security headers (such as Content-Security-Policy, X-Frame-Options, Referrer-Policy, and Cache-Control) required by the organization's style guide for all HTML responses.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
There was a problem hiding this comment.
All reported issues were addressed across 28 files
Architecture diagram
sequenceDiagram
participant Client as Browser Client
participant Router as axum Router
participant AdminRouter as admin_router()
participant UI as ui Module (embedded)
participant Bundle as ui/dist (rust-embed)
participant API as Admin API Handlers
participant CI as CI/Release Pipeline
participant Node as Node.js Toolchain
Note over Client,API: Runtime Request Flow with --features ui
Client->>Router: GET /admin/assets/{path}
Router->>AdminRouter: Route to /admin/assets/{*path}
AdminRouter->>UI: asset() handler
UI->>Bundle: Bundle::get("assets/{path}")
alt Asset found
Bundle-->>UI: file bytes + extension
UI->>UI: mime_guess::from_path()
UI-->>Client: 200 + Content-Type + X-Content-Type-Options: nosniff
else Asset not found
UI-->>Client: 404 (not_found shape)
end
Client->>Router: GET /admin/{deep_link}
Router->>AdminRouter: Route to /admin/{*path}
AdminRouter->>UI: shell() handler
UI->>Bundle: Bundle::get("index.html")
alt Shell exists
Bundle-->>UI: index.html bytes
UI-->>Client: 200 + text/html + nosniff (SPA shell)
else Shell missing
UI-->>Client: 500 internal error
end
Client->>Router: GET /admin/api/{unmatched}
Router->>AdminRouter: Route to /admin/api/{*path} (any method)
AdminRouter->>UI: api_not_found() handler
UI-->>Client: 404 + JSON (Anthropic error shape)
Client->>Router: GET /admin
Router->>AdminRouter: Exact route match (static beats catch-all)
AdminRouter->>API: dashboard() handler
API-->>Client: 302 redirect to /admin/login (server-rendered)
Note over Client,API: Auth Boundary - Shell/assets unauthenticated, API authenticated
Client->>Router: GET /admin/assets/{path}
Note over Router: No admin session check
Router-->>Client: Served without auth (like /admin/login)
Client->>Router: GET /admin/api/...
Note over Router: Admin authentication enforced
Router->>API: Check credentials (header/x-api-key)
alt Valid credentials
API-->>Client: JSON response
else Invalid credentials
API-->>Client: 401/403
end
Note over CI,Node: Build Pipeline - Embedding the Bundle
CI->>Node: Setup Node 22 + npm ci
Node->>Node: npm run build (vite build)
Node-->>CI: ui/dist bundle generated
CI->>CI: cargo build --features ui
CI->>Bundle: rust-embed embeds ui/dist at compile time
Note over CI,Node: Default build (no --features ui)
CI->>CI: cargo build (no ui feature)
Note over CI: No Node needed, no bundle embedded,<br/>routes not registered
Note over Client,API: Route Resolution Priority (verified)
Note over Router: Static /admin > /admin/api/{*path} > /admin/{*path}
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…SPA shell
A `{*path}` wildcard segment must match at least one character, so
`/admin/api` and `/admin/api/` did not match `/admin/api/{*path}` and fell
through to `/admin/{*path}`, answering the HTML shell with `200` — the exact
failure the separate JSON catch-all exists to prevent. Register both roots
explicitly and pin them with tests.
Also from the review round:
- Give the SPA shell the admin surface's full HTML response policy — CSP,
`X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`,
and `Cache-Control: no-store` — matching `html_body_with_form_action`. The
CSP needs no `'unsafe-inline'`: the emitted `ui/dist/index.html` references
an external module script and an external stylesheet, nothing inline.
- Serialize the env-var writes in `tests/admin_ui.rs` behind a file-local
lock. The hazard is a writer racing a *reader* — `build_router` reads the
environment — which unique variable names do not address.
- Assert the JSON catch-all answers every method, and that each legacy admin
path is now only a `GET,HEAD` SPA deep link.
- Correct the `docs/admin-ui-delivery.md` fallback claim: `[server.spend]`
does attach `MethodRouter::fallback` to its two paths, which shapes the
response to a wrong *method* on a path that already matched and never sees
an unmatched path.
- Document that the shell and its assets are unauthenticated, and that the
from-source `--features ui` build needs Node.js 22.12 or newer.
|
/gemini review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 320f55c11a
ℹ️ 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".
There was a problem hiding this comment.
Code Review
This pull request integrates an embedded admin Single Page Application (SPA) dashboard built with React and Vite into the binary under the ui feature flag, introducing routes to serve the SPA shell and assets under the /admin mount. The code review highlights a high-severity path traversal vulnerability in the asset-serving handler due to a lack of validation on wildcard path parameters, recommending checks for directory traversal segments and absolute paths. To support this fix, the reviewer suggests adding both unit and integration tests. Additionally, the review points out a concurrency data race and potential test flakiness in the router surface tests caused by concurrent modifications to process-global environment variables.
…face env `rust-embed`'s `debug-embed` feature (`Cargo.toml`) embeds `ui/dist` in debug builds too, so `Bundle::get` is a lookup in a map generated at compile time and no build of this crate reaches the filesystem to serve an asset. That makes a traversal segment simply an absent key — but the property belongs to a feature flag rather than to the handler, so pin it in tests instead of leaving it to a comment: drop `debug-embed` and a debug build starts reading `ui/dist` from disk, which is what these would catch. Two levels, because they can fail apart. The unit test proves the handler resolves nothing; the router test proves the probe cannot reach a *different* handler on the way in — a router that normalized the URI before matching would leave the unit test green. All four probes (raw, percent-encoded, doubly encoded, and the `....//` collapse) answer `404`, and neither test can pass on a deleted route while `an_asset_is_served_with_its_own_bytes_and_type` stands as its positive twin. Also give `tests/router_surface.rs` the `ENV_LOCK` that `tests/admin_ui.rs` gained earlier in this PR. Its per-test-unique variable names stop one test's value from satisfying another's config, but the hazard `set_var` actually poses is a writer racing a **reader**, and `server::build_router` reads the environment while a sibling test may be writing it — so the lock spans the writes, that read, and the `EnvVars` cleanup.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8fbdcc5fe2
ℹ️ 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".
`endpoints.md` promised that the old `/admin/*` JSON paths "now answer 404".
That is true of a default build, where they are registered nowhere — and false
of the prebuilt release binaries, which ship `--features ui`: there a `GET` to a
former path falls through to the `/admin/{*path}` SPA fallback like any other
deep link under the mount and returns the `200 text/html` shell.
The router is behaving as designed, and both halves are already pinned:
`every_legacy_admin_path_is_now_only_an_spa_deep_link` asserts exactly
`GET,HEAD` with the feature on, and `no_legacy_admin_path_survives_the_api_split`
asserts the paths are unregistered with it off. Only the documentation stated
one of the two as unconditional, which is the half that matters to a scripted
caller: it would read a `200` as success and never see the migration signal.
State both builds, note that every non-`GET` method still answers `404`, and
point callers at `Content-Type` as the reliable check. English page plus the
ko/ja/zh-cn copies, per the same-PR translation rule.
`docs/admin-ui-delivery.md` needed no change: it says the paths are "removed,
not aliased" without asserting a status, and its Resolution 6 already states
that the SPA claims those deep links.
There was a problem hiding this comment.
All reported issues were addressed across 7 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
The previous commit replaced one wrong status with another. It said that under
`--features ui` "every other method still answers `404`" on a former
`/admin/*` path. Two errors: `/admin/{*path}` is registered with `get`, not
`any`, so a `POST`/`PUT`/`DELETE` matches the path but not the method and axum
answers `405` with `Allow: GET,HEAD`; and `HEAD` is part of `get`, so it
returns the `200` shell rather than any error.
Probed rather than reasoned about:
GET /admin/accounts -> 200
HEAD /admin/accounts -> 200
POST /admin/accounts -> 405 Allow: GET,HEAD
DELETE /admin/accounts -> 405 Allow: GET,HEAD
PUT /admin/accounts -> 405 Allow: GET,HEAD
The distinction is deliberate elsewhere in the router and already covered:
`/admin/api/{*path}` is registered with `any` precisely so its unmatched paths
stay `404` instead of becoming `405`, and
`every_legacy_admin_path_is_now_only_an_spa_deep_link` reads exactly this
`Allow` header to assert the legacy paths expose only `GET,HEAD`. So the
behaviour was pinned by a test while the sentence describing it was wrong —
for the second time in this PR.
English page plus the ko/ja/zh-cn copies; site build 161 pages, clean.
…ant is stated `src/server.rs` said the admin handlers "authenticate every request against the separate `[server.admin]` credential". Adding the SPA made that false without touching the line: `ui::shell` and `ui::asset` authenticate nothing, which is deliberate and documented in `src/admin/ui.rs` — but a reader checking the invariant looks where the invariant is stated, not at the module that breaks it. Found by auditing every factual claim this PR's diff touches against the code, after three documentation defects in review. This one was in a surface the diff never edited, which is why the earlier per-file passes missed it. The admin module doc gains the same exception for the same reason. Both name the reason the shell is safe to serve unauthenticated — it carries no operator data, and everything the SPA reads sits behind `/admin/api/*`.
|



Summary
Step 3 of ADR-0003's UI platform track: the delivery machinery for the admin SPA, with none of the views. Steps 1 and 2 landed as #497 and #499.
ui/— React + Vite + TypeScript package with its own npm lockfile, separate fromsite/'s Astro toolchain (Resolution 2).base: '/admin/'makes the emitted asset URLs/admin/assets/....--features ui— embedsui/distwithrust-embed, types each asset withmime_guess. A defaultcargo buildneeds no Node toolchain and carries no bundle (Resolution 1); release CI enables the feature./admin/assets/{*path}serves the bundle withX-Content-Type-Options: nosniff,/admin/{*path}serves the shell so a deep link survives a reload, and/admin/api,/admin/api/and/admin/api/{*path}answer404for every method. The two JSON roots need their own registration because a{*path}segment must match at least one character, so they would otherwise fall through to the shell — that was a real bug in review, caught by greptile and fixed in320f55c1.Closes #501.
Two scoping decisions
GET /adminstill serves the string-literal dashboard, feature on or off. Porting the views is step 4; flipping/adminto an empty shell now would leave the dashboard broken between the two steps./admin/api/returns404, not the shell. Decision 3's table makes/admin/api/*a namespace distinct from the UI's/admin/*, and "Why not the root" item 2 names HTML-instead-of-404as the exact failure to avoid.CI needs Node now
ci.ymlruns--all-featuresin three places (clippy,cargo test,cargo llvm-cov) and installed no Node, so all three would fail the momentuiembedsui/dist. Resolution 1 only committed to release CI enabling the feature; extending Node to the test jobs is the direct consequence of--all-features.Milestone / spec
docs/admin-ui-delivery.md— Decision 3 (mount split), Decision 4 (embedded bundle), Resolutions 1 and 2; sequenced by ADR-0003 track 1 item 3.Checklist
cargo buildpassescargo testpasses — 2568 with--all-features, 2554 without (unchanged frommain, so the feature is genuinely off by default)cargo clippy --all-targets -- -D warningscleancargo fmt --all --checkcleandocs/updated —admin-ui-delivery.md's "no wildcard or fallback route anywhere" claim is now false as written and is scoped to outside the mount; the "Current surface" table gains the rowreference/endpoints.mdandgetting-started/installation.mdx, each with its ko/ja/zh-cn copyactions/setup-node@48b55a01…, the same pindeploy-docs.ymlanddeploy-wiki.ymlalready useNotes for reviewers
The route inventory got stronger, not weaker.
tests/router_surface.rsis cfg-aware now: the source scans read the three new literals unconditionally (a#[cfg]does not remove.route("…"from the source text, so the literal count moves 34 → 39 either way), while the runtime probes switch on the feature.no_legacy_admin_path_survives_the_api_splitcould not simply stay — under the feature every legacy path does answer, as a deep link. Rather than drop it, it gained a feature-on twin asserting each legacy path answers onlyGET,HEAD, which is a stronger claim: a survivingPOST-only mutation orDELETEalias would widen theAllowset and fail.Worth a close look:
/admin/login. They carry no operator data and everything the SPA will read is behind/admin/api/*. Reasoned about insrc/admin/ui.rs's module doc — say so if you want the shell behind the session cookie instead.Router::merge, and matchit resolves as the design assumed — static and{param}beat catch-alls, and the longer static prefix/admin/api/beats/admin/.site/was built locally (161 pages, exit 0), since ci: no workflow builds site/ on a pull request #394 means PR CI never builds it and the MDX edits would otherwise ship unverified.The full-suite flake from the first draft is now identified — and it is not this PR. It was not a failure but a hang:
admin::plan::tests::read_failures_survive_a_timed_out_file_phaseparked at 0.0% CPU for 34 minutes, with the harness main thread waiting inmpmc::Channel<CompletedTest>::recv. That test builds a real FIFO withmkfifoand relies on an ordering its own comment already documents as deadlock-prone;cargo testhas no per-test timeout, so the whole binary stops instead of reporting. It does not reproduce in isolation (12/12 clean), this PR never touchessrc/admin/plan.rs, and CI ran the test green on every head here. Filed as #505 rather than touched, and deliberately not attributed further than that — there is no control-arm measurement onmain.Review rounds
16 bot threads, all resolved. One was a real code defect — greptile's P1 above. The other three were documentation defects, all on the same behaviour, and each fix introduced the next:
docs/admin-ui-delivery.mdclaimed no wildcard or fallback route existed anywhere;[server.spend]does attachMethodRouter::fallbackto two paths (cubic).endpoints.mdsaid the old/admin/*paths "now answer404" — true of a default build, false of the prebuilt releases, where aGETgets the200 text/htmlshell (codex,0e975116).404"; it is405withAllow: GET,HEAD, andHEADreturns the200shell rather than an error (cubic,a991b4da).A fourth I found myself, by auditing every factual claim in the diff against the code afterwards:
src/server.rsstated that the admin handlers "authenticate every request", which this PR made false without editing that line (c2401dd8). It sits in a file the diff never touched, which is why the per-file passes missed it.Worth flagging for the reviewer: the behaviour in defects 2 and 3 was already pinned by
every_legacy_admin_path_is_now_only_an_spa_deep_link, which reads exactly thatAllowheader. A passing test did not stop the prose describing it from being wrong twice.Two findings were declined with evidence rather than applied:
debug-embedis what prevents filesystem access — it embedsui/distat compile time in debug builds too, soBundle::getis a map lookup and a traversal segment is an absent key. Measured, not argued: four probes (raw, percent-encoded, doubly encoded,....//) all404at both the handler and the router. The suggestedcontains("..")guard would be dead on every build and would misleadingly imply the lookup is unsafe. Its test suggestions were applied anyway (8fbdcc5f), because the property lives in a Cargo feature flag rather than in the handler.tests/router_surface.rsalready used per-test-unique names; the hazardset_varposes is a writer racing a reader, which renaming cannot address. Applied theENV_LOCKused intests/admin_ui.rsinstead.Follow-ups filed: #504 (the root README row, which belongs with step 4 — writing it today would advertise a placeholder) and #505.