Skip to content

feat(rm-handlers): W1 — Issue list + detail handlers#10

Merged
AdaWorldAPI merged 1 commit into
mainfrom
claude/w1-issue-handlers
Jun 21, 2026
Merged

feat(rm-handlers): W1 — Issue list + detail handlers#10
AdaWorldAPI merged 1 commit into
mainfrom
claude/w1-issue-handlers

Conversation

@AdaWorldAPI

Copy link
Copy Markdown
Owner

First Phase-1 width track from the Redmine Integration Plan

W1 — Issue (project_work_item). The hot-path resource — Redmine's
Issue, OpenProject's WorkPackage, both routing through
class_ids::PROJECT_WORK_ITEM (0x0102). End-to-end render goes
through the OGAR askama kit; the kit inherits Redmine's 17-yr UX
(ColumnKind, formatters) so the handler body stays ~80 LOC.

What this adds

  • rm-handlers crate — the Phase-1 fan-out point. Each width
    track (W2..W8) adds one module here, no two parallel tracks edit
    the same file (Plan §8 file ownership).
  • rm-handlers/src/issues.rs — W1 routes:
    • GET /issues — list view (columns # + Subject)
    • GET /issues/:id — detail view
  • rm-handlers/src/common.rs — shared AppState, wrap_in_doc,
    record_id_to_u64
  • rm-store gains Store::list_issues() returning Vec<IssueRow>
  • rm-server gains build_router_with(store, auth_cfg)
    the merge file where width tracks add their resource routers
    alphabetised by URL path

Live end-to-end

$ cargo run -p rm-server &
$ curl -s http://localhost:3000/issues | grep -oE 'No data\.'
No data.

$ curl -s -X POST http://localhost:3000/login -d 'login=jsmith&password=jsmith' -c /tmp/jar
jsmith

# (W3 D1 lands the create form; today we seed directly via store API.)

DoD pinned by 7 tests in rm-handlers + 3 new in rm-store

  • empty store → empty-state list page
  • two seeded issues → both visible + detail hrefs
  • detail page → 200 + canonical class_id + subject
  • detail 404 → NotFound from store maps cleanly
  • common helpers: wrap_in_doc shape, record_id_to_u64 deterministic + distinguishes

All three CI gates green locally: fmt + clippy + cargo test --workspace (58 tests across 5 crates).

Notable

  • HandlerError::Render(String)askama is transitive through
    ogar-render-askama; declaring it directly here would re-trigger
    the askama_axum macro-expansion issue we hit in W0.1. Map at the
    call site.
  • SurrealDB 3.x RecordId doesn't impl Display; uses
    ToSql::to_sql() for URL hrefs + the stable hash input.
  • rm-handlers does NOT depend on rm-server — rm-server consumes
    rm-handlers, reverse dep would cycle. Plan §8 dep DAG holds.

What this unlocks

Phase 1 parallel fan-out — W2..W8 follow the same shape (one
file in rm-handlers, one alphabetised .merge in rm-server's
build_router_with).

🤖 Generated with Claude Code

W1 of the Redmine Integration Plan, first Phase-1 width track. The
hot-path resource: Redmine's `Issue`, OpenProject's `WorkPackage`,
both routing through `class_ids::PROJECT_WORK_ITEM` (0x0102).

# What this adds

- New `rm-handlers` crate. Each Phase-1 width track (W2..W8) adds
  one module here owning a single canonical concept's routes — per
  Plan §8 file ownership, no two parallel tracks edit the same file.
- `rm-handlers/src/issues.rs` — the W1 module. Routes:
  - `GET /issues` — list page, columns `#` + `Subject`
  - `GET /issues/:id` — detail page
- `rm-handlers/src/common.rs` — shared `AppState`, `wrap_in_doc` HTML
  shell (G1 master template replaces this later), `record_id_to_u64`
  (URL → render-kit u64 adapter via stable hash).
- rm-store gains `Store::list_issues()` — empty Vec on a fresh store
  is the empty-state path the kit renders as "No data.".
- rm-server: new `build_router_with(store, auth_cfg)` mounts all
  resource routers + the rm-auth surface. `serve()` boots the
  store + auth config and wires the full app. The bare
  `build_router()` stays for the proof-of-shape index test.

# DoD pinned by 7 tests (in addition to the 3 store-list ones)

- common: wrap_in_doc shape, record_id_to_u64 deterministic + distinguishes
- issues:
  - list_renders_empty_state_when_no_issues — empty store → empty-state
  - list_renders_seeded_issues — two issues both visible + detail hrefs
  - detail_renders_a_known_issue — seeded row → 200 + subject + class_id
  - detail_returns_404_for_unknown_issue — NotFound → 404 via IntoResponse

# Notable

- HandlerError carries `Render(String)` not `Render(askama::Error)`.
  `askama` is transitive through `ogar-render-askama`; declaring it
  as a direct dep here would trigger the same `askama_axum` macro
  expansion mess we hit in W0.1. Map at the call site with
  `.map_err(|e| HandlerError::Render(e.to_string()))`.
- SurrealDB 3.x `RecordId` doesn't impl `Display`; uses `ToSql::to_sql()`
  for stable string formatting (URL hrefs + the hash input).
- rm-handlers does NOT depend on rm-server — rm-server consumes
  rm-handlers (the W1..W8 merges live in rm-server's router), and a
  reverse dep would cycle.

# What this unlocks

Phase 1 parallel fan-out: W2 (Project), W3 (TimeEntry), W4 (User/
Member/Role), W5..W8 each follow the same shape — one file in
rm-handlers, one alphabetised .merge call in rm-server's
build_router_with.

cargo fmt --check + cargo clippy --all-targets -- -D warnings +
cargo test --workspace all green locally; 58 tests across 5 crates.

@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: 852d977a06

ℹ️ 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".

)
.map_err(|e| HandlerError::Render(e.to_string()))?;
Ok(Html(wrap_in_doc(
&format!("#{} {}", rid.key.to_sql(), &issue.subject),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Escape issue subjects before setting the page title

For an issue whose subject contains markup such as </title><script>...</script>, GET /issues/:id sends that subject verbatim into wrap_in_doc, which interpolates the title directly into <title>{title}</title>. Since issue subjects are stored user-controlled data, this creates stored XSS on the detail page; escape the subject before passing it to the document wrapper.

Useful? React with 👍 / 👎.

@AdaWorldAPI AdaWorldAPI merged commit 61aaeee into main Jun 21, 2026
1 check passed
AdaWorldAPI added a commit that referenced this pull request Jun 21, 2026
…ail-fast

Closes the codex items left open across my Phase 0/1 PRs:

# P1 — XSS via raw user content in handler-built HTML (#10, #12)

`wrap_in_doc(title, body)` interpolated the raw `title` straight
into the document's `<title>` element. With user-controlled inputs
(issue subjects, time-entry spent_on, project names, user display
names) reaching this fn, a payload like
`</title><script>alert(1)</script>` is a stored XSS.

The `render_detail(..., headline_html, ...)` parameter is
contractually pre-rendered HTML (kit `|safe`s it through the
template), so handlers that compose it from user data must escape
themselves. W1 (Issue), W2 (Project), W3 (TimeEntry), W4a (User),
W4b (Role) all did this with raw interpolation.

Fix:
- `common::html_escape` — minimal `& < > " '` escape, no extra dep.
- `wrap_in_doc` escapes its `title` parameter.
- Every handler that composes `headline_html` from user data now
  calls `html_escape` first.
- New regression test
  `detail_escapes_xss_in_subject_for_title_and_headline` in
  `issues.rs` exercises the full XSS surface.

# P2 — URL slug encoding (#13)

`/roles/<name>`, `/projects/<identifier>`, `/users/<login>` paths
interpolated the slug raw. A name with `#`/`?`/`/` would break the
browser's URL parse (`#` = fragment, `/` = path segment).

Fix:
- `common::encode_path_segment` — percent-encodes everything outside
  RFC-3986 unreserved + path-safe chars. No new dep (rule is small).
- Roles, projects, users all route hrefs through it.
- New regression test
  `list_percent_encodes_role_names_with_reserved_chars` in
  `roles.rs` (Q/A → Q%2FA, R&D → R%26D).

# P1 — RM_BIND fail-fast (#7)

`std::env::var("RM_BIND").ok().and_then(|s| s.parse().ok())`
collapsed unset and malformed into the same fallback. A typo
silently started the server on the default. Now we distinguish:
- Unset → default
- Malformed → return `io::Error::other(...)` immediately

# Skipped

- #9 (auth routes not reachable) — already fixed by #10's
  `build_router_with` refactor that mounts `rm_auth::router(cfg)`.
- #8 (lowercase vs PascalCase OGAR table names) — schema-
  divergence noted in W3's doc, lands in a dedicated PR; needs the
  per-port DDL emission story W4-followups will revisit.

cargo fmt --check + cargo clippy --all-targets -- -D warnings +
cargo test --workspace all green locally; 108 tests across 5 crates.
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