Skip to content

feat(admin): scaffold the embedded admin SPA behind --features ui - #503

Merged
amondnet merged 6 commits into
mainfrom
amondnet/admin-spa-scaffold
Sep 10, 2026
Merged

amondnet merged 6 commits into
mainfrom
amondnet/admin-spa-scaffold

Conversation

@amondnet

@amondnet amondnet commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

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 from site/'s Astro toolchain (Resolution 2). base: '/admin/' makes the emitted asset URLs /admin/assets/....
  • --features ui — embeds ui/dist with rust-embed, 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.
  • Five registrations 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, /admin/api/ and /admin/api/{*path} answer 404 for 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 in 320f55c1.

Closes #501.

Two scoping decisions

  1. GET /admin still serves the string-literal dashboard, feature on or off. Porting the views is step 4; flipping /admin to an empty shell now would leave the dashboard broken between the two steps.
  2. An unmatched path under /admin/api/ returns 404, 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-404 as the exact failure to avoid.

CI needs Node now

ci.yml runs --all-features in three places (clippy, cargo test, cargo llvm-cov) and installed no Node, so all three would fail the moment ui embeds ui/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 build passes
  • cargo test passes — 2568 with --all-features, 2554 without (unchanged from main, so the feature is genuinely off by default)
  • cargo clippy --all-targets -- -D warnings clean
  • cargo fmt --all --check clean
  • Source files stay under 500 lines
  • English only; matches surrounding style
  • Frozen spec in docs/ 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 row
  • User-facing docs updated — reference/endpoints.md and getting-started/installation.mdx, each with its ko/ja/zh-cn copy
  • Any new GitHub Action is pinned to a full commit SHA — actions/setup-node@48b55a01…, the same pin deploy-docs.yml and deploy-wiki.yml already use

Notes for reviewers

The route inventory got stronger, not weaker. tests/router_surface.rs is 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_split could 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 only GET,HEAD, which is a stronger claim: a surviving POST-only mutation or DELETE alias would widen the Allow set and fail.

Worth a close look:

  • The shell and assets are served unauthenticated, like /admin/login. They carry no operator data and everything the SPA will read is behind /admin/api/*. Reasoned about in src/admin/ui.rs's module doc — say so if you want the shell behind the session cookie instead.
  • axum wildcard coexistence was verified with a throwaway probe before implementing: no route-conflict panic at 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_phase parked at 0.0% CPU for 34 minutes, with the harness main thread waiting in mpmc::Channel<CompletedTest>::recv. That test builds a real FIFO with mkfifo and relies on an ordering its own comment already documents as deadlock-prone; cargo test has no per-test timeout, so the whole binary stops instead of reporting. It does not reproduce in isolation (12/12 clean), this PR never touches src/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 on main.

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:

  1. docs/admin-ui-delivery.md claimed no wildcard or fallback route existed anywhere; [server.spend] does attach MethodRouter::fallback to two paths (cubic).
  2. endpoints.md said the old /admin/* paths "now answer 404" — true of a default build, false of the prebuilt releases, where a GET gets the 200 text/html shell (codex, 0e975116).
  3. That fix then said every other method "still answers 404"; it is 405 with Allow: GET,HEAD, and HEAD returns the 200 shell 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.rs stated 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 that Allow header. A passing test did not stop the prose describing it from being wrong twice.

Two findings were declined with evidence rather than applied:

  • gemini's HIGH path-traversal report has an inverted premise. debug-embed is what prevents filesystem access — it embeds ui/dist at compile time in debug builds too, so Bundle::get is a map lookup and a traversal segment is an absent key. Measured, not argued: four probes (raw, percent-encoded, doubly encoded, ....//) all 404 at both the handler and the router. The suggested contains("..") 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.
  • The suggested env-race remedy was already in place. tests/router_surface.rs already used per-test-unique names; the hazard set_var poses is a writer racing a reader, which renaming cannot address. Applied the ENV_LOCK used in tests/admin_ui.rs instead.

Follow-ups filed: #504 (the root README row, which belongs with step 4 — writing it today would advertise a placeholder) and #505.

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.
@socket-security

socket-security Bot commented Sep 10, 2026 •

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist 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.

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.

Comment thread src/admin/ui.rs
@codecov

codecov Bot commented Sep 10, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.59155% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/admin/ui.rs 98.41% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@amondnet
amondnet marked this pull request as ready for review September 10, 2026 14:48
@greptile-apps

greptile-apps Bot commented Sep 10, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

The PR should not merge until the /admin/api namespace roots reliably return the documented JSON 404; the shell response-policy gap is also worth correcting.

Fix All in Claude CodeFindings

  1. P1 API roots serve HTML ▶
  2. P2 Shell drops response protections ▶
Fix with agent prompt
### Issue 1
src/admin/mod.rs:273
The `/admin/api/{*path}` catch-all requires a non-empty wildcard. Requests to `/admin/api` or `/admin/api/` therefore bypass it and match the broader `/admin/{*path}` route. These namespace-root requests receive the SPA shell as HTML with status 200 instead of the intended JSON 404. Please register the API roots explicitly or otherwise include them in the API fallback.

### Issue 2
src/admin/ui.rs:85-90
The SPA shell sets only its content type and `nosniff`, omitting the response policy used by the existing admin HTML-particularly `Cache-Control: no-store` and a same-origin Content Security Policy. This can leave clients with a stale shell after bundle upgrades and removes the browser-enforced resource boundary applied to other admin pages. Please apply an SPA-compatible policy such as `script-src 'self'`, along with the existing cache, framing, and referrer controls.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

  • Default Rust builds remain independent of Node and do not include the SPA.
  • Release and all-features CI jobs build ui/dist before compiling Rust.
  • The route split mostly preserves the intended JSON/UI boundary, but the /admin/api namespace roots currently escape the JSON fallback.
  • The SPA shell should retain the response-policy protections used by existing admin HTML.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    Request[Request under /admin] --> Exact{Exact server route?}
    Exact -->|Yes| Handler[Existing authenticated admin handler]
    Exact -->|No| Asset{Path under /admin/assets/?}
    Asset -->|Yes| Bundle[Serve embedded asset]
    Asset -->|No| Api{Path under /admin/api/?}
    Api -->|Non-empty tail| Json404[Return JSON 404]
    Api -->|Namespace root| ShellBug[Currently falls through to HTML shell]
    Api -->|No| Shell[Serve embedded SPA shell]
Loading

Reviews (1) · Last reviewed commit: "feat(admin): scaffold the embedded admin..."

Comment thread src/admin/mod.rs
Comment thread src/admin/ui.rs

@cubic-dev-ai cubic-dev-ai 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.

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}
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/admin/ui.rs
Comment thread tests/router_surface.rs
Comment thread site/src/content/docs/ko/getting-started/installation.mdx Outdated
Comment thread docs/admin-ui-delivery.md Outdated
Comment thread site/src/content/docs/ko/reference/endpoints.md
Comment thread tests/admin_ui.rs
@codspeed

codspeed Bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 83 untouched benchmarks


Comparing amondnet/admin-spa-scaffold (c2401dd) with main (67c27ca)

Open in CodSpeed

…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.
@amondnet

Copy link
Copy Markdown
Contributor Author

/gemini review

@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: 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".

Comment thread Cargo.toml

@gemini-code-assist gemini-code-assist 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.

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.

Comment thread src/admin/ui.rs
Comment thread src/admin/ui.rs
Comment thread tests/admin_ui.rs
Comment thread tests/router_surface.rs
…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.

@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: 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".

Comment thread site/src/content/docs/reference/endpoints.md
`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.

@cubic-dev-ai cubic-dev-ai 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.

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

Comment thread site/src/content/docs/reference/endpoints.md Outdated
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/*`.
@sonarqubecloud

Copy link
Copy Markdown

@amondnet
amondnet merged commit 84f6249 into main Sep 10, 2026
15 checks passed
@amondnet
amondnet deleted the amondnet/admin-spa-scaffold branch September 10, 2026 16:33
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.

feat(admin): SPA scaffold — own package, --features ui, asset embedding, /admin-confined fallback

1 participant