From 17a553d6730b8dd90f632db2b86776fef244a0ae Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 08:35:18 +0000 Subject: [PATCH 01/27] docs(spec): in-app feedback via github-proxy worker Adds the design for a willow-feedback worker that proxies in-app feedback to GitHub issues, plus a settings-page UI in willow-web. Reuses the existing worker request/response gossip pathway rather than introducing a new ALPN for v1. --- .../2026-04-27-feedback-system-design.md | 345 ++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 docs/specs/2026-04-27-feedback-system-design.md diff --git a/docs/specs/2026-04-27-feedback-system-design.md b/docs/specs/2026-04-27-feedback-system-design.md new file mode 100644 index 00000000..efc07a56 --- /dev/null +++ b/docs/specs/2026-04-27-feedback-system-design.md @@ -0,0 +1,345 @@ +# In-App Feedback via GitHub-Proxy Worker + +> **One-sentence summary:** add an in-app "Send Feedback" form in the web +> UI that submits to a new `willow-feedback` worker node, which holds a +> GitHub PAT and creates issues on `intendednull/willow` on the user's +> behalf — so users can report bugs, suggestions, or other issues without +> needing a GitHub account. + +## Motivation + +Willow has no in-app channel for user feedback. Users hitting a bug +either silently churn, find the GitHub repo on their own, or page the +maintainers out-of-band. We want a one-click path from "something is +wrong" to "the maintainer sees a structured report" without: + +- Requiring users to have a GitHub account (most won't). +- Standing up new external infra to maintain (no email forwarder, no + Cloudflare Worker, no third-party tracker). +- Polluting the event-sourced state DAG with reports that aren't shared + state and shouldn't replicate to every peer. + +A worker node is the right shape: it's the existing pattern for +project-run services that hold privileged credentials (relay, +replay, storage), and it keeps the feedback path off the per-server +state DAG. + +## Scope + +**In scope (v1):** + +- New `willow-feedback` worker binary (alongside `willow-replay` and + `willow-storage`). +- Settings → "Help & Feedback" UI in the Leptos web app: title, + category, description, "include diagnostic info" checkbox. +- A `Feedback` request/response variant added to the existing + `WorkerRequest`/`WorkerResponse` enums in `willow-common`. +- Per-peer rate limiting (5 reports/hour) and length caps on the + feedback worker. +- A `Client::submit_feedback(...)` method on `willow-client`. +- The project-run feedback worker peer ID configurable via env/config + (same shape as relay/replay/storage configuration today). + +**Out of scope (deferred to a larger redesign):** + +- Attachments (logs, screenshots). +- Threaded replies / two-way conversation with the reporter. +- Per-server feedback workers (each server admin running their own). +- Feedback that lives in the per-server DAG. +- Encrypted-to-worker feedback over a dedicated ALPN. V1 reuses the + existing gossip-based worker request pathway — see + [Trade-offs](#trade-offs). + +## Architecture + +### New crate: `willow-feedback` + +Native-only worker binary. Mirrors the structure of `willow-replay` and +`willow-storage`: + +``` +crates/feedback/ +├── Cargo.toml +└── src/ + ├── main.rs — CLI parsing, identity load, IrohNetwork bring-up + ├── role.rs — FeedbackRole : WorkerRole + └── github.rs — Thin HTTP client around POST /repos/:owner/:repo/issues +``` + +Built on `willow-worker`'s actor runtime: the role implements +`WorkerRole::handle_request`, the runtime handles identity, networking, +heartbeat, and request routing. + +**Configuration (CLI flags + env):** + +| Flag | Env | Required | Notes | +| --- | --- | --- | --- | +| `--identity-path` | — | yes | Ed25519 keypair for the worker peer | +| `--relay-url` | — | optional | Iroh relay to connect through | +| `--github-token` | `GITHUB_TOKEN` | yes | GitHub PAT with `issues:write` | +| `--github-repo` | `FEEDBACK_REPO` | yes (default: `intendednull/willow`) | `owner/repo` to file issues against | +| `--rate-limit-per-hour` | — | optional, default 5 | Per-peer cap | + +The PAT is read once at startup; never logged; stored only in the +role's memory. A misconfigured worker (missing token, unreachable repo) +fails closed and replies `Denied` to every request. + +### Wire types in `willow-common` + +Extend the existing `WorkerRequest` / `WorkerResponse` enums and +`WorkerRoleInfo` rather than introducing parallel types. This keeps the +worker dispatch path unchanged and lets the feedback role plug into the +same actor runtime as replay and storage. + +```rust +// Added to WorkerRoleInfo +WorkerRoleInfo::Feedback { + reports_accepted: u64, + reports_rejected: u64, + rate_limited_peers: u32, +} + +// Added to WorkerRequest +WorkerRequest::Feedback { + title: String, // <= 200 chars + category: FeedbackCategory, + body: String, // <= 8000 chars + diagnostics: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub enum FeedbackCategory { Bug, Suggestion, Other } + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct FeedbackDiagnostics { + pub app_version: String, // CARGO_PKG_VERSION + pub build_hash: Option, // git short SHA at build time, if present + pub user_agent: String, // browser UA (web only) or "native" + pub platform: String, // "wasm32" | "linux" | "macos" | "windows" +} + +// Added to WorkerResponse +WorkerResponse::FeedbackOk { issue_url: String } +WorkerResponse::FeedbackErr { reason: FeedbackErrReason } + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub enum FeedbackErrReason { + RateLimited { retry_after_secs: u32 }, + InvalidInput { field: String, message: String }, + GithubFailure { status: u16 }, + Unconfigured, +} +``` + +Note the reporter's peer ID is **not** in `FeedbackRequest` — it's +recovered from the `WireMessage` envelope's signer at the worker side +via the existing `unpack_wire` path. That guarantees the reported peer +ID matches the actual signer; a forged ID in the payload would be +ignored. + +### Client API in `willow-client` + +```rust +impl Client { + pub async fn submit_feedback( + &self, + feedback_worker: EndpointId, + title: String, + category: FeedbackCategory, + body: String, + include_diagnostics: bool, + ) -> Result; +} + +pub enum FeedbackError { + NotConnected, + WorkerUnreachable, + Timeout, + RateLimited { retry_after_secs: u32 }, + InvalidInput { field: String, message: String }, + GithubFailure { status: u16 }, + Unconfigured, + Internal(String), +} +``` + +The client serializes the request through the same `WorkerRequest` +gossip path used by replay/history requests, awaits the matching +`WorkerResponse` (correlated by `request_id`), and maps it to +`FeedbackError`. + +### Web UI in `willow-web` + +- Entry point: **Settings page → "Help & Feedback" section**, with a + "Send Feedback" button that opens a modal. (No top-bar icon, no + command palette entry in v1 — settings only, per the explicit scope + decision during brainstorming. Adding more entry points later is + trivial once `Client::submit_feedback` exists.) +- Modal contents: + - Title input (single line, `<= 200` chars; counter visible past 150). + - Category dropdown: Bug / Suggestion / Other (default: Bug). + - Body textarea (`<= 8000` chars; counter visible past 7500). + - "Include diagnostic info" checkbox, default **checked**, with a + disclosure showing exactly what would be attached (app version, + build hash, user agent, platform). Diagnostics are visible to the + user before submission — no surprises. + - Submit / Cancel buttons. +- States: `Idle → Submitting → Success(issue_url) | Failure(reason)`. + - Success shows the issue URL with a "Open issue" link and a "Send + another" button. + - Failure renders a human-friendly mapping of `FeedbackError` and + keeps the form populated so the user can retry. +- Configuration: the feedback worker peer ID is loaded from web-app + configuration alongside the existing relay/worker bootstrap config. + If unset, the form is disabled with a clear "Feedback is not + configured for this build" message. + +### GitHub issue format + +The worker constructs the issue body deterministically: + +```markdown +**Reported by peer:** `` +**Category:** Bug +**App version:** 0.1.0 +**Build:** abc1234 +**Platform:** wasm32 (Mozilla/5.0 ...) + +--- + + +``` + +Title is the user-supplied title with a `[Bug]` / `[Suggestion]` / +`[Other]` prefix added by the worker so issues are scannable in the +GitHub UI. Labels: `feedback`, plus one of `feedback:bug`, +`feedback:suggestion`, `feedback:other`. The worker creates these +labels lazily on first use. + +The peer ID is included as a stable pseudonymous handle so maintainers +can correlate multiple reports from the same user without exposing a +display name. Diagnostics are included only if the user opted in. + +### Abuse protection on the worker + +- **Rate limit:** in-memory token bucket keyed by signer peer ID; + default 5 requests / hour with a 1-hour refill. Resets on worker + restart (acceptable for v1 — see [Follow-ups](#follow-ups)). +- **Length validation:** title `<= 200`, body `<= 8000`; reject with + `InvalidInput` before contacting GitHub. +- **Signature verification:** already enforced by `unpack_wire` on the + inbound gossip path, so the worker only ever sees signed messages + with a verified signer. +- **GitHub API failures:** non-2xx responses surface as + `GithubFailure { status }`; the worker does not retry. The user can + retry from the UI. + +The worker does **not** moderate content. Issues are filed verbatim +into a public repository, so abuse is bounded by the rate limit and by +GitHub's own moderation tooling. + +### Deployment + +- Add a `Dockerfile` for the feedback worker mirroring + `crates/replay/Dockerfile` / `crates/storage/Dockerfile`. +- Add a `feedback` service to `docker-compose.yml` and to `just dev` + alongside the existing replay/storage services. +- Add `just build-feedback` and integrate into `just docker-build`. +- The local `just dev` stack runs the feedback worker with a *blank* + GitHub token by default — it accepts requests, validates them, and + returns `Unconfigured`. This lets developers exercise the UI flow + end-to-end without leaking a real PAT into local environments. + +## Trade-offs + +**Reused gossip request path vs. dedicated encrypted ALPN.** During +brainstorming we initially proposed a new `/willow/feedback/0` ALPN +with direct iroh request/response. Inspecting the existing worker +infrastructure showed that replay and storage already share a single +gossip-based request/response pathway (`_willow_workers` topic, +`WorkerWireMessage::Request/Response`). Reusing that pathway keeps v1 +drastically simpler — no new transport code, no new dispatcher, no +parallel correlation logic — at the cost of feedback request payloads +being visible to other peers subscribed to `_willow_workers`. Since +v1's reports are destined for a public GitHub issue anyway, that's an +acceptable trade-off for the initial cut. A dedicated encrypted ALPN +is on the follow-up list with the broader feedback redesign. + +**In-memory rate limit vs. persistent.** A restart resets every +peer's bucket. For a single instance with light load this is fine — +the worst case is "abuser restarts the worker and reports 5 more +times before the next refill," which is bounded. Persistent buckets +add a SQLite dependency (or piggyback on storage worker) for marginal +benefit; defer until we see actual abuse. + +**Diagnostics opt-in default checked.** Defaulting to **checked** +trades a little user privacy for dramatically more useful reports. +The disclosure makes the contents explicit, and the user can opt out +per-report. We considered defaulting unchecked, but reports without +version/build info are nearly useless for triage and require a +maintainer round-trip to ask for them. + +**Hard-coded repo target.** Configurable via env so a fork can point +at its own repo, but there is no per-server / per-user override. v1 +is for the upstream project; multi-tenant routing belongs to the +larger redesign. + +## Testing + +- **`willow-common` unit tests:** round-trip the new + `WorkerRequest::Feedback`, `WorkerResponse::FeedbackOk` / + `FeedbackErr`, and `WorkerRoleInfo::Feedback` variants through + bincode and through the full `pack_wire` / `unpack_wire` path + (alongside the existing worker round-trip tests). +- **`willow-feedback` role tests:** exercise `FeedbackRole` directly + with a mock GitHub client trait — happy path, rate limit + enforcement, length validation, GitHub failure mapping, missing-token + `Unconfigured` path. No live HTTP. +- **`willow-feedback` HTTP unit tests:** the GitHub client module + parses representative GitHub API responses (success, 422 validation + error, 403 abuse detection, 404 repo not found) into + `FeedbackErrReason`. +- **`willow-client` test:** add to `crates/client/src/tests/`. Stand + up a mock worker via `MemNetwork`, send a feedback request, assert + the `Client::submit_feedback` future resolves to the issue URL. + Includes a rate-limit-mapping test and a worker-unreachable test. +- **`willow-web` browser test:** in `crates/web/tests/browser.rs`, + mount the settings page, open the feedback modal, fill the form, + assert the submit handler is called with the right `FeedbackRequest` + and that success/failure UI states render correctly. Use a stubbed + client (no real network). +- **No Playwright E2E for v1.** Per the project's testing policy, the + multi-peer scenarios this would cover (peer A submits, worker B + forwards) are exercised by the client-tier test against + `MemNetwork`. The only thing Playwright would add is "real iroh + transport," which we already cover for replay/storage and don't + need to re-cover here. + +## Follow-ups + +These are explicitly **not** part of this spec but should land in the +follow-ups list when v1 ships: + +- Larger feedback redesign (the user has ideas requiring major + refactoring — capture in a separate spec when ready). +- Dedicated encrypted ALPN so feedback bodies are not visible to other + workers-topic subscribers. +- Persistent rate-limit buckets (SQLite or shared with storage). +- Attachment support (recent log buffer, screenshot capture). +- Two-way replies — the worker writes a comment-back path so a + maintainer's GitHub reply lands in the reporter's UI. +- Per-server feedback workers and per-server routing. +- A "Send feedback" command-palette entry and a "?" top-bar shortcut + once the form is proven. + +## Open questions + +None at spec time. Brainstorming resolved: + +- Delivery target → GitHub issues via a project-run worker. +- Transport → existing worker gossip request/response pathway (not a + new ALPN). +- UI entry point → Settings page only, in v1. +- Rate limit → 5 / hour / peer. +- Anonymity → signed by the user's identity, peer ID included in the + issue as a pseudonymous handle; no display name. From 399afa67b2c311fbe75c8b158f3a7c7b8c574a0a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 04:01:06 +0000 Subject: [PATCH 02/27] docs(spec): address round-1 review of feedback-system spec Round-1 parallel review (architecture / wire / security / scope) flagged ~10 critical and ~25 notable issues. Major changes: - Make WorkerRole::handle_request async (was sync, blocked HTTP from inside the role); document migration impact for replay/storage. - Add forward-compat note: v1 workers drop the whole envelope on unknown variants; mark the affected enums #[non_exhaustive] and document why PROTOCOL_VERSION is not bumped. - Align FeedbackErrReason with WireRejectReason units (ms not secs); add idempotency dedup_id, structured GithubFailure { message }, typed ClientPlatform enum, FeedbackCategory::Other { detail }, locale, currently_rate_limited gauge. - Drop EndpointId from public Client::submit_feedback API; resolve worker peer ID from client config; return parsed url::Url. - Spec __WILLOW_FEEDBACK_PEER_ID window global wiring through init.js plus .dev/feedback-peer-id dev plumbing through dev.sh. - Mandatory fenced-code-block sanitization of user body (defeats @mentions, autolinks, image exfil, metadata-block spoofing). - Add global 50/hour rate limit (per-peer alone is bypassed by free identity rotation); 401 transitions to Unconfigured permanently; 403 secondary-rate-limit cooldown; restart-throttle. - Salted-hash reporter handle (rotateable salt for incident response); no raw peer ID in issue body; coarse-grained UA only. - secrecy::SecretString for PAT; no Debug derive; logging never contains PAT/salt/title/body. - Per-request structured logs; configured-but-unreachable UI states with explicit copy table and GitHub-direct fallback link. - Fix Docker paths (docker/feedback.Dockerfile, not crates/...); --print-peer-id / --generate-identity flags; depends_on: relay; GITHUB_TOKEN via compose .env. - Honest content-moderation trade-off; expanded follow-ups list. --- .../2026-04-27-feedback-system-design.md | 849 +++++++++++++++--- 1 file changed, 708 insertions(+), 141 deletions(-) diff --git a/docs/specs/2026-04-27-feedback-system-design.md b/docs/specs/2026-04-27-feedback-system-design.md index efc07a56..132d8e33 100644 --- a/docs/specs/2026-04-27-feedback-system-design.md +++ b/docs/specs/2026-04-27-feedback-system-design.md @@ -29,16 +29,27 @@ state DAG. **In scope (v1):** - New `willow-feedback` worker binary (alongside `willow-replay` and - `willow-storage`). + `willow-storage`), with sibling `docker/feedback.Dockerfile` + + `docker/feedback-entrypoint.sh`. - Settings → "Help & Feedback" UI in the Leptos web app: title, category, description, "include diagnostic info" checkbox. - A `Feedback` request/response variant added to the existing - `WorkerRequest`/`WorkerResponse` enums in `willow-common`. -- Per-peer rate limiting (5 reports/hour) and length caps on the - feedback worker. -- A `Client::submit_feedback(...)` method on `willow-client`. -- The project-run feedback worker peer ID configurable via env/config - (same shape as relay/replay/storage configuration today). + `WorkerRequest` / `WorkerResponse` enums in `willow-common`, plus a + `Feedback` variant in `WorkerRoleInfo`. +- A cross-cutting change to `WorkerRole::handle_request` to make it + `async` (replay and storage become async fns that don't `.await` + anything — see [Async trait change](#async-trait-change)). +- Per-peer **and** worker-wide rate limiting + length caps on the + feedback worker. User-supplied content sanitized via mandatory + fenced-code-block wrapping. +- A `Client::submit_feedback(...)` method on `willow-client`, with + the feedback worker peer ID resolved from client config (not a + caller parameter). +- A new `__WILLOW_FEEDBACK_PEER_ID` window global wired through + `crates/web/init.js` for the web app, plus `.dev/feedback-peer-id` + plumbing through `scripts/dev.sh` for local development. +- A `just test-feedback` target and a `feedback` service in + `docker-compose.yml`. **Out of scope (deferred to a larger redesign):** @@ -49,13 +60,21 @@ state DAG. - Encrypted-to-worker feedback over a dedicated ALPN. V1 reuses the existing gossip-based worker request pathway — see [Trade-offs](#trade-offs). +- Persistent rate-limit buckets across worker restarts. +- Consolidating `FeedbackErrReason` into the broader `WireRejectReason` + proposed in [`2026-04-24-error-prefixes.md`](./2026-04-24-error-prefixes.md). + V1 ships a feedback-local error enum with units aligned to + `WireRejectReason` (ms, not secs); migration is a pure refactor + once that spec lands. +- Real-time content moderation. v1 relies on pre-flight sanitization + + GitHub-side moderation tools — see [Trade-offs](#trade-offs). ## Architecture ### New crate: `willow-feedback` -Native-only worker binary. Mirrors the structure of `willow-replay` and -`willow-storage`: +Native-only worker binary. Mirrors `willow-replay` and `willow-storage` +both in crate layout and in the surrounding docker/dev plumbing: ``` crates/feedback/ @@ -63,26 +82,91 @@ crates/feedback/ └── src/ ├── main.rs — CLI parsing, identity load, IrohNetwork bring-up ├── role.rs — FeedbackRole : WorkerRole - └── github.rs — Thin HTTP client around POST /repos/:owner/:repo/issues + └── github.rs — Thin reqwest-based client around POST /repos/:owner/:repo/issues + +docker/ +├── feedback.Dockerfile — sibling to replay.Dockerfile / storage.Dockerfile +└── feedback-entrypoint.sh — sibling to replay-entrypoint.sh ``` Built on `willow-worker`'s actor runtime: the role implements `WorkerRole::handle_request`, the runtime handles identity, networking, heartbeat, and request routing. -**Configuration (CLI flags + env):** +**HTTP client choice.** `reqwest` with `rustls-tls` (matches iroh's TLS +stack — no native OpenSSL dep). The client lives **only** in +`willow-feedback`'s `Cargo.toml`; `willow-common` stays dual-target, +so the new wire types added there (see below) must remain WASM-clean. + +**CLI flags + env:** | Flag | Env | Required | Notes | | --- | --- | --- | --- | | `--identity-path` | — | yes | Ed25519 keypair for the worker peer | | `--relay-url` | — | optional | Iroh relay to connect through | -| `--github-token` | `GITHUB_TOKEN` | yes | GitHub PAT with `issues:write` | +| `--github-token` | `GITHUB_TOKEN` | yes | Fine-grained PAT, `Issues: write` on the target repo only | | `--github-repo` | `FEEDBACK_REPO` | yes (default: `intendednull/willow`) | `owner/repo` to file issues against | | `--rate-limit-per-hour` | — | optional, default 5 | Per-peer cap | +| `--global-rate-limit-per-hour` | — | optional, default 50 | Worker-wide ceiling (see [Abuse](#abuse-protection-on-the-worker)) | +| `--generate-identity` | — | flag | Generate keypair at `--identity-path` and exit | +| `--print-peer-id` | — | flag | Print the bech32 peer ID for `--identity-path` and exit (used by `just docker-ids`) | + +**PAT handling.** The PAT is wrapped in +`secrecy::SecretString` at load time and stored only inside +`FeedbackRole`. The role struct **must not** derive `Debug`; a clippy +`missing_debug_implementations` allow with a `// security: PAT` comment +makes the intent explicit. A unit test asserts the role does not +`Debug`-format. A misconfigured worker (missing token, unreachable +repo, wrong scope) fails closed and replies +`FeedbackErr { reason: Unconfigured }` to every request without +contacting GitHub. + +### Async trait change + +`WorkerRole::handle_request` is currently a synchronous method +(`crates/common/src/worker_types.rs:139`), called synchronously from +the state actor's message handler +(`crates/worker/src/actors/state.rs`). Replay and storage compute +their responses entirely in memory, so sync was sufficient. + +A feedback worker must perform an HTTP call to GitHub before it can +return a response. We change the trait to: + +```rust +#[async_trait::async_trait] +pub trait WorkerRole: Send + 'static { + fn role_info(&self) -> WorkerRoleInfo; + fn on_event(&mut self, event: &Event); + async fn handle_request(&mut self, req: WorkerRequest) -> WorkerResponse; + fn heads_summaries(&self) -> Vec<(String, HeadsSummary)> { vec![] } +} +``` -The PAT is read once at startup; never logged; stored only in the -role's memory. A misconfigured worker (missing token, unreachable repo) -fails closed and replies `Denied` to every request. +Replay and storage become trivial converts — `async fn handle_request` +that doesn't `.await` anything. The state actor's +`Handler` is already `async fn handle(...)`, so it +just needs to `.await` the role's response. While `handle_request` is +running on the state actor, no other message is processed for that +role — this is the existing concurrency invariant and we preserve it. +A slow GitHub call therefore back-pressures further feedback requests +on the same worker, which is the right behavior (and combined with +the rate limits, bounds the worst case). + +**Trade-off considered:** alternatively, `FeedbackRole::handle_request` +could spawn a tokio task that owns the HTTP work and signals back via a +oneshot channel, leaving the trait sync. Rejected because it requires +either (a) extending the actor's request/response correlation to handle +late replies, or (b) blocking inside `handle_request` on a spawned +task, which is the same back-pressure as async with extra plumbing. +Async-trait is the simpler, smaller diff. The cost — pulling in +`async-trait` (or using a manually-written `Pin>` +return type) — is acceptable. + +**Migration impact.** Every `impl WorkerRole` must add `async` to +`handle_request` (replay, storage, the in-test `TestRole` in +`crates/worker/src/actors/state.rs:113`, and the in-test `TestSyncRole` +in `crates/worker/src/actors/sync.rs:108`). No call sites change other +than the actor's `.await`. ### Wire types in `willow-common` @@ -91,82 +175,200 @@ Extend the existing `WorkerRequest` / `WorkerResponse` enums and worker dispatch path unchanged and lets the feedback role plug into the same actor runtime as replay and storage. +All three enums also gain `#[non_exhaustive]` so future variants don't +silently break consumers that match exhaustively. The annotation is a +consumer-side compile guard; **bincode forward compatibility is +discussed in [Trade-offs](#trade-offs)**. + ```rust -// Added to WorkerRoleInfo +// Added to WorkerRoleInfo (#[non_exhaustive] applied to the enum). WorkerRoleInfo::Feedback { reports_accepted: u64, reports_rejected: u64, - rate_limited_peers: u32, + /// Gauge: peers currently throttled by the per-peer bucket. + currently_rate_limited: u32, + /// Gauge: 1 if the worker is hot-tripped on the global cap, else 0. + global_rate_limited: bool, } +// AND: the existing match in `WorkerRoleInfo::role_name()` +// (crates/common/src/worker_types.rs:40-45) gains +// WorkerRoleInfo::Feedback { .. } => "feedback", -// Added to WorkerRequest +// Added to WorkerRequest (#[non_exhaustive] applied to the enum). WorkerRequest::Feedback { - title: String, // <= 200 chars + /// 16-byte client-generated dedup key. Worker maintains an LRU + /// cache of (signer, dedup_id) → issue_url so retries after a + /// network blip return the original URL instead of opening a + /// duplicate issue. + dedup_id: [u8; 16], + /// 1..=200 chars. Bytes-counted, not graphemes. + title: String, category: FeedbackCategory, - body: String, // <= 8000 chars + /// 1..=8000 chars. The worker wraps this verbatim in a fenced + /// markdown code block when posting to GitHub (see + /// "GitHub issue format"); clients MUST NOT pre-format it. + body: String, diagnostics: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -pub enum FeedbackCategory { Bug, Suggestion, Other } +#[non_exhaustive] +pub enum FeedbackCategory { + Bug, + Suggestion, + Other { + /// Optional free-form subcategory. <= 60 chars. Surfaced as + /// part of the issue title so triage isn't a black hole. + detail: Option, + }, +} #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[non_exhaustive] pub struct FeedbackDiagnostics { - pub app_version: String, // CARGO_PKG_VERSION - pub build_hash: Option, // git short SHA at build time, if present - pub user_agent: String, // browser UA (web only) or "native" - pub platform: String, // "wasm32" | "linux" | "macos" | "windows" + /// CARGO_PKG_VERSION of the submitting client. + pub app_version: String, + /// Short git SHA from `option_env!("WILLOW_BUILD_SHA")` injected + /// by `build.rs`. None in dev builds. + pub build_hash: Option, + /// IETF BCP 47 locale tag (e.g. "en-US"); helps triage RTL / + /// date-format / pluralisation bugs. + pub locale: Option, + /// Coarse-grained UA: browser family + major version only + /// ("firefox/138", "chrome/130"). Full UA strings are *not* + /// shipped — see "Privacy" in Trade-offs. + pub client: ClientPlatform, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[non_exhaustive] +pub enum ClientPlatform { + Web { + /// e.g. "firefox/138". <= 40 chars. + ua_family: String, + }, + Native { + /// "linux" | "macos" | "windows". + os: String, + /// "x86_64" | "aarch64" | etc. + arch: String, + }, } -// Added to WorkerResponse +// Added to WorkerResponse (#[non_exhaustive] applied to the enum). WorkerResponse::FeedbackOk { issue_url: String } WorkerResponse::FeedbackErr { reason: FeedbackErrReason } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[non_exhaustive] pub enum FeedbackErrReason { - RateLimited { retry_after_secs: u32 }, + /// Units are MILLISECONDS to align with the broader + /// WireRejectReason design (docs/specs/2026-04-24-error-prefixes.md). + /// Consolidating with that enum is on the follow-up list. + RateLimited { retry_after_ms: u64 }, InvalidInput { field: String, message: String }, - GithubFailure { status: u16 }, + GithubFailure { + status: u16, + /// GitHub's `message` field, truncated to 200 chars. Useful + /// for surfacing 422 validator failures in maintainer logs + /// without re-running the failing call. + message: Option, + }, + /// Worker has no PAT configured, or PAT was revoked (401) — + /// see [Abuse](#abuse-protection-on-the-worker). Unconfigured, } ``` -Note the reporter's peer ID is **not** in `FeedbackRequest` — it's -recovered from the `WireMessage` envelope's signer at the worker side -via the existing `unpack_wire` path. That guarantees the reported peer -ID matches the actual signer; a forged ID in the payload would be -ignored. +**Reporter peer ID.** The reporter's peer ID is **not** carried in +`FeedbackRequest`. The worker recovers it from the `WireMessage` +envelope's verified signer via the existing `unpack_wire` path +(`crates/transport/src/lib.rs`, returning `(WireMessage, EndpointId)`). +A forged peer ID in any payload would be ignored. A unit test asserts +this invariant so a future refactor can't silently regress it. + +**Per-variant size cap.** `WireMessage::Worker(_)` currently inherits +the 256 KB envelope cap (`crates/common/src/wire.rs:144`). Worst-case +feedback envelope is 200 (title) + 8000 (body) + 60 (detail) + +diagnostics ≈ 9 KB, well under cap. Decoder-side validation happens +*before* the worker accepts the request: title/body length, dedup +shape, diagnostics field lengths. ### Client API in `willow-client` +The client knows the feedback worker's peer ID from configuration set +at construction time (mirroring how relay/replay/storage workers are +discovered today). The public method does NOT take an `EndpointId` +parameter — exposing it would leak bootstrap config into every UI +caller. + ```rust -impl Client { - pub async fn submit_feedback( - &self, - feedback_worker: EndpointId, - title: String, - category: FeedbackCategory, - body: String, - include_diagnostics: bool, - ) -> Result; -} +// In willow-client config (e.g. ClientConfig): +pub feedback_worker: Option, +// In willow-client::error (or wherever client error types live; +// match the existing pattern): +#[derive(Debug, thiserror::Error)] pub enum FeedbackError { + #[error("client is not connected to a network")] NotConnected, + #[error("feedback worker is not configured for this build")] + NotConfigured, + #[error("feedback worker is unreachable")] WorkerUnreachable, + #[error("request timed out")] Timeout, - RateLimited { retry_after_secs: u32 }, + #[error("rate limited; retry after {retry_after_ms}ms")] + RateLimited { retry_after_ms: u64 }, + #[error("invalid input in field {field}: {message}")] InvalidInput { field: String, message: String }, - GithubFailure { status: u16 }, - Unconfigured, + #[error("github returned {status}: {message:?}")] + GithubFailure { status: u16, message: Option }, + #[error("worker returned a malformed issue url: {0}")] + BadIssueUrl(String), + #[error("internal: {0}")] Internal(String), } + +impl Client { + /// Submit feedback to the configured feedback worker. + /// Returns the GitHub issue URL on success. + /// Returns NotConfigured if no feedback worker is set. + pub async fn submit_feedback( + &self, + title: String, + category: FeedbackCategory, + body: String, + include_diagnostics: bool, + ) -> Result; +} ``` -The client serializes the request through the same `WorkerRequest` -gossip path used by replay/history requests, awaits the matching -`WorkerResponse` (correlated by `request_id`), and maps it to -`FeedbackError`. +Implementation: + +1. The client generates a random `[u8; 16]` `dedup_id` per call. +2. Builds `FeedbackDiagnostics` from compile-time constants + (`CARGO_PKG_VERSION`, `WILLOW_BUILD_SHA`) and runtime sniffing + (`window.navigator` on web, `std::env::consts::{OS, ARCH}` on + native) — but only if `include_diagnostics` is true. Otherwise + `diagnostics: None`. +3. Wraps in `WorkerRequest::Feedback`, sends through the same + `WorkerWireMessage::Request` gossip path used by replay/history + requests, awaits the matching `WorkerResponse` (correlated by + `request_id`). +4. On `FeedbackOk { issue_url }`, parses with `url::Url::parse`. A + parse failure returns `FeedbackError::BadIssueUrl(...)` rather than + handing a malformed string to the UI. +5. Maps `FeedbackErrReason` variants to `FeedbackError` variants + one-to-one, preserving units (ms). + +A 30-second total timeout (gossip round-trip + GitHub API) covers the +client side; longer than that surfaces as `FeedbackError::Timeout`. + +**Idempotency**: if the user retries after a network blip, the same +`dedup_id` MUST be reused. The web UI keeps `dedup_id` in component +state between attempts and only regenerates it when the form is +cleared / "Send another" is clicked. ### Web UI in `willow-web` @@ -177,78 +379,273 @@ gossip path used by replay/history requests, awaits the matching trivial once `Client::submit_feedback` exists.) - Modal contents: - Title input (single line, `<= 200` chars; counter visible past 150). - - Category dropdown: Bug / Suggestion / Other (default: Bug). + - Category dropdown: Bug / Suggestion / Other. + Selecting "Other" reveals a `detail` text input (<= 60 chars). + Default: Bug. - Body textarea (`<= 8000` chars; counter visible past 7500). - - "Include diagnostic info" checkbox, default **checked**, with a - disclosure showing exactly what would be attached (app version, - build hash, user agent, platform). Diagnostics are visible to the - user before submission — no surprises. + - "Include diagnostic info" checkbox, default **checked**, with an + expandable disclosure that renders the **exact** + `FeedbackDiagnostics` value that will be sent (app version, build + hash if any, locale, and either `Web { ua_family: "firefox/138" }` + or `Native { os, arch }`). What you see is what is sent — no UA + string surprises. - Submit / Cancel buttons. - States: `Idle → Submitting → Success(issue_url) | Failure(reason)`. - Success shows the issue URL with a "Open issue" link and a "Send - another" button. + another" button. "Send another" clears the form *and* the + component-local `dedup_id`. - Failure renders a human-friendly mapping of `FeedbackError` and - keeps the form populated so the user can retry. -- Configuration: the feedback worker peer ID is loaded from web-app - configuration alongside the existing relay/worker bootstrap config. - If unset, the form is disabled with a clear "Feedback is not - configured for this build" message. + keeps both the form and `dedup_id` populated so the user can + retry idempotently. Retry of the same `dedup_id` returns the + original issue URL if GitHub already received it. + +#### Failure-state copy + +| `FeedbackError` | UI surface | +| --- | --- | +| `NotConnected` | Inline error: "You're offline. Reconnect and try again." Form stays open. | +| `NotConfigured` | Form is disabled at mount; settings panel renders "Feedback is not configured for this build. File an issue on GitHub instead." | +| `WorkerUnreachable` | Inline error: "The feedback service is currently unavailable. Please try again later, or file an issue on GitHub directly." with a fallback link. | +| `Timeout` | Inline error: "Submitting timed out. Check your connection and retry." | +| `RateLimited { retry_after_ms }` | Inline error: "You've sent too many reports. Try again in {N} minutes." | +| `InvalidInput { field, message }` | Field-level error highlighting the offending field. | +| `GithubFailure { status, message }` | Inline error: "GitHub rejected the report ({status}). {message}" — direct user to file directly on GitHub if persistent. | +| `BadIssueUrl(_)` | Inline error: "Submitted but the response was malformed. Check the GitHub issues page for your report." | +| `Internal(_)` | Inline error: "Something went wrong. Please retry." Logs full string to console. | + +The fallback "file directly on GitHub" link is always present in the +failure-state UI, hand-built with the user's title and body +url-encoded (`https://github.com/intendednull/willow/issues/new?title=...&body=...`). +Worst case, every path to feedback works. + +#### Configuration mechanism + +There is **no** existing web-app config for a worker peer ID — relay +URL is the only externally configured peer in `crates/web/init.js` +today. We add a parallel mechanism: + +- New window global `__WILLOW_FEEDBACK_PEER_ID` (bech32 string), set by + `crates/web/init.js` (production: from env injected at container + start; dev: from the local-dev plumbing below). If unset, the web + app is "not configured" and renders the `NotConfigured` state above. +- `crates/web/init.js` is already the place that picks up + `__WILLOW_RELAY_URL` and falls back to localhost in dev; the + feedback peer ID follows the same pattern. For dev, when + `location.hostname` is `127.0.0.1` or `localhost`, init.js attempts + to fetch `/.dev/feedback-peer-id` (a static file served by + `trunk serve`) and assigns the result to + `__WILLOW_FEEDBACK_PEER_ID`. +- The web app reads the global at `Client` construction and stores the + parsed `EndpointId` in `ClientConfig::feedback_worker`. ### GitHub issue format -The worker constructs the issue body deterministically: +The worker constructs the issue body deterministically. **The +user-supplied body is wrapped in a fenced markdown code block** — +this is the single most important sanitization step: -```markdown -**Reported by peer:** `` +````markdown +**Reporter (salted hash):** `whisper-quiet-fern-3a9c` **Category:** Bug **App version:** 0.1.0 **Build:** abc1234 -**Platform:** wasm32 (Mozilla/5.0 ...) +**Locale:** en-US +**Client:** Web { ua_family: "firefox/138" } ---- +> Submitted via willow-feedback. The reporter's body is rendered +> verbatim in the fenced block below; @mentions, links, and image +> syntax inside it are **not** processed by GitHub. +```text ``` - -Title is the user-supplied title with a `[Bug]` / `[Suggestion]` / -`[Other]` prefix added by the worker so issues are scannable in the -GitHub UI. Labels: `feedback`, plus one of `feedback:bug`, -`feedback:suggestion`, `feedback:other`. The worker creates these -labels lazily on first use. - -The peer ID is included as a stable pseudonymous handle so maintainers -can correlate multiple reports from the same user without exposing a -display name. Diagnostics are included only if the user opted in. +```` + +Sanitization rules applied by the worker before assembly: + +1. **Body wrapping.** User body is placed inside a `​```text` fenced + block. Before insertion, the worker verifies the body does not + contain a closing ` ``` ` sequence; if it does, the worker swaps + the fence to a longer backtick run (` ```` ` → ` ````` ` etc.) + that doesn't appear in the body. This neutralizes `@mention` + notification spam, autolinks, image-pixel exfiltration, and + metadata-block spoofing. +2. **Title sanitization.** Strip control chars, collapse internal + whitespace, escape leading `[` / `]`. Final title is + `[Bug] ` / `[Suggestion] ` / + `[Other:] ` (the `` segment is also + sanitized). +3. **Total body cap.** After assembly, the worker asserts the + composed issue body is `<= 60 KB` (well under GitHub's 65 KB + limit). Over-cap is a worker-side bug, not a user-facing error; + reject with `Internal`. + +**Reporter handle.** The peer ID is **not** posted in cleartext. +The worker computes +`hash = blake3(worker_salt || peer_id_bytes)[..6]` and renders it +as a [bip39-style] human-friendly four-word phrase + 4-hex suffix +(`whisper-quiet-fern-3a9c`). The salt is loaded from +`--reporter-salt-file` (default: `/etc/willow/feedback-salt`, +generated on first run with `--generate-salt`). This: +- Lets maintainers correlate multiple reports from the same user + without exposing the public Ed25519 key that signs the user's + state-DAG events on every Willow server they participate in. +- Rotating the salt resets all correlation, which is the right + knob for incident response. + +**Labels.** `feedback`, plus one of `feedback:bug`, +`feedback:suggestion`, `feedback:other`, plus +`feedback:triage` (always applied). Maintainers remove +`feedback:triage` after review and apply real workflow labels. +The worker creates labels lazily on first use. + +Diagnostics are included only if the user opted in (the checkbox in +the UI controls `diagnostics: Option` directly). +With diagnostics omitted, the metadata block shows +`(diagnostics not provided)` after the category line. ### Abuse protection on the worker -- **Rate limit:** in-memory token bucket keyed by signer peer ID; - default 5 requests / hour with a 1-hour refill. Resets on worker - restart (acceptable for v1 — see [Follow-ups](#follow-ups)). -- **Length validation:** title `<= 200`, body `<= 8000`; reject with - `InvalidInput` before contacting GitHub. -- **Signature verification:** already enforced by `unpack_wire` on the - inbound gossip path, so the worker only ever sees signed messages +- **Per-peer rate limit:** in-memory token bucket keyed by signer + peer ID; default 5 requests / hour with a 1-hour refill. +- **Global rate limit:** in-memory token bucket worker-wide; default + 50 requests / hour with a 1-hour refill. **This is the main + abuse-bound** because Ed25519 keys are free to generate (per-peer + buckets do not constrain a determined attacker rotating + identities). The global cap turns the attack from "unbounded + spam" into "saturate the worker's bucket and stop." Operators can + raise/lower at runtime via the CLI flags. +- **Restart-loop hardening:** worker process supervisor (Docker + restart policy) uses exponential-backoff restart, and the worker + refuses to start more than once per 15 seconds (a tiny gating + file under the identity dir, touched on startup). This bounds + rate-limit reset abuse without needing persistent buckets. +- **Length and shape validation:** title 1..=200, body 1..=8000, + detail 0..=60, dedup_id length, diagnostics field caps. Rejection + is `InvalidInput` *before* contacting GitHub. +- **Signature verification:** already enforced by `unpack_wire` on + the inbound gossip path, so the worker only sees signed messages with a verified signer. - **GitHub API failures:** non-2xx responses surface as - `GithubFailure { status }`; the worker does not retry. The user can - retry from the UI. - -The worker does **not** moderate content. Issues are filed verbatim -into a public repository, so abuse is bounded by the rate limit and by -GitHub's own moderation tooling. + `GithubFailure { status, message }`; the worker does not retry + individual requests. The user can retry idempotently via the same + `dedup_id`. +- **Secondary-rate-limit detection.** A `403` with + `x-ratelimit-remaining: 0` (GitHub's secondary rate limit, signal + of abuse-throttling) trips a worker-wide cooldown for the + duration in `retry-after`. While cooled down, the worker replies + `RateLimited { retry_after_ms }` to all callers. +- **PAT revocation.** A `401` response transitions the worker to + the `Unconfigured` state for the rest of the process lifetime; + all subsequent requests reply `Unconfigured`. Operator restart + with a fresh PAT is required. +- **Idempotency cache.** An LRU of + `(signer_peer_id, dedup_id) → issue_url` (capacity 4096 entries) + short-circuits retries that arrive within the cache window; the + worker returns the original `FeedbackOk { issue_url }` without + contacting GitHub. + +The worker does **not** moderate content beyond sanitization. +Issues are filed into the configured public repository and rely on +GitHub's own moderation tooling. Trade-offs and the residual abuse +risk are documented explicitly in [Trade-offs](#trade-offs). ### Deployment -- Add a `Dockerfile` for the feedback worker mirroring - `crates/replay/Dockerfile` / `crates/storage/Dockerfile`. -- Add a `feedback` service to `docker-compose.yml` and to `just dev` - alongside the existing replay/storage services. -- Add `just build-feedback` and integrate into `just docker-build`. -- The local `just dev` stack runs the feedback worker with a *blank* - GitHub token by default — it accepts requests, validates them, and - returns `Unconfigured`. This lets developers exercise the UI flow - end-to-end without leaking a real PAT into local environments. +**Docker.** Sibling files alongside replay and storage: + +- `docker/feedback.Dockerfile` (mirrors `docker/replay.Dockerfile`). +- `docker/feedback-entrypoint.sh` (mirrors `docker/replay-entrypoint.sh`). +- New `feedback` service in `docker-compose.yml`: + + ```yaml + feedback: + build: + context: . + dockerfile: docker/feedback.Dockerfile + depends_on: + - relay + environment: + - GITHUB_TOKEN=${GITHUB_TOKEN} # from .env, NOT committed + - FEEDBACK_REPO=${FEEDBACK_REPO:-intendednull/willow} + - RUST_LOG=info,willow_feedback=debug + volumes: + - feedback-identity:/etc/willow + volumes: + feedback-identity: + ``` + + `GITHUB_TOKEN` is loaded from `.env` (which `docker-compose` reads + natively); `.env` is `.gitignore`-d. There is no `secrets:` block + because v1 targets a single-host docker-compose deployment; promoting + to a real secrets backend is a follow-up. + +**Justfile additions:** + +- `just build-feedback` — `cargo build --release -p willow-feedback`. +- `just docker-build` — gains the feedback image. +- `just docker-ids` — prints feedback peer ID alongside replay/storage + via the binary's `--print-peer-id` flag. +- `just test-feedback` — `cargo test -p willow-feedback`. + +**Local dev plumbing.** `scripts/dev.sh` already manages relay, +replay, and storage workers under `.dev/`. The feedback worker is +added analogously: + +1. On first run, `scripts/dev.sh` invokes + `cargo run -p willow-feedback -- --identity-path .dev/feedback.key + --generate-identity` if the keypair is absent. +2. Then `cargo run -p willow-feedback -- --identity-path + .dev/feedback.key --print-peer-id > .dev/feedback-peer-id`. +3. The dev web app is served from a directory that includes + `.dev/feedback-peer-id` as `/.dev/feedback-peer-id` (configure + trunk's `--ignore` / static dir as needed; the simplest option is + to add a `dev_assets/` symlink). `crates/web/init.js` fetches it + on dev hostnames and assigns to `__WILLOW_FEEDBACK_PEER_ID`. +4. The dev feedback worker runs **without** a `GITHUB_TOKEN`. It + accepts and validates requests fully, but every successful path + replies `FeedbackErr { reason: Unconfigured }` instead of touching + GitHub. This exercises every UI path end-to-end (idempotency + cache, rate limit, sanitization, error surfaces) without leaking + a real PAT into local environments. + +**Production peer ID injection.** `docker/web.Dockerfile` does not +own the feedback peer ID — the deployment's web container entrypoint +reads `WILLOW_FEEDBACK_PEER_ID` from the environment and injects it +into the served `init.js` at startup (a tiny `sed` step in the +existing entrypoint, mirroring how the relay URL is injected today). +If unset, the form renders `NotConfigured`. + +### Observability + +Per-request logging is critical for debugging "user X says they +submitted at 14:02 but no issue exists." Each `handle_request` invocation +emits exactly one structured log line: + +``` +INFO feedback_request id= signer=… + category=Bug body_len=243 dedup= github_status=201 + issue= latency_ms=412 +``` + +Log fields: + +- `id` — `WorkerWireMessage::Request::request_id`. +- `signer` — first 8 chars of the bech32 peer ID (full ID at debug + level only; salted hash at info to limit cross-server correlation + surface in operator logs). +- `category` — Bug / Suggestion / Other(detail). +- `body_len` — bytes (not chars). +- `dedup` — first 8 hex chars of `dedup_id`. +- `github_status` — HTTP status from GitHub, or `cache` if served + from the idempotency cache, or `rate-limited` / `invalid` / + `unconfigured` / `cooldown` for non-GitHub paths. +- `issue` — GitHub issue URL on success, omitted otherwise. +- `latency_ms` — total request latency. + +The PAT, the salt, the user body, and the user title are **never** +logged. A unit test asserts none of these strings appear in any log +output the role emits during a happy-path or error-path call. ## Trade-offs @@ -267,79 +664,249 @@ is on the follow-up list with the broader feedback redesign. **In-memory rate limit vs. persistent.** A restart resets every peer's bucket. For a single instance with light load this is fine — -the worst case is "abuser restarts the worker and reports 5 more -times before the next refill," which is bounded. Persistent buckets -add a SQLite dependency (or piggyback on storage worker) for marginal -benefit; defer until we see actual abuse. +combined with the global cap and the 15-second restart-throttle, +worst-case abuse is "saturate the worker, restart, saturate again +once per 15 seconds." Persistent buckets (SQLite or piggyback on +storage worker) are deferred until we see actual abuse. **Diagnostics opt-in default checked.** Defaulting to **checked** trades a little user privacy for dramatically more useful reports. -The disclosure makes the contents explicit, and the user can opt out -per-report. We considered defaulting unchecked, but reports without -version/build info are nearly useless for triage and require a -maintainer round-trip to ask for them. +The disclosure renders the *exact* `FeedbackDiagnostics` value that +will be sent — no UA string surprises — and the user can opt out +per-report. Reports without version/build info are nearly useless for +triage and require a maintainer round-trip to ask for them. **Hard-coded repo target.** Configurable via env so a fork can point at its own repo, but there is no per-server / per-user override. v1 is for the upstream project; multi-tenant routing belongs to the larger redesign. +**Forward compatibility.** Adding `WorkerRequest::Feedback` / +`WorkerResponse::FeedbackOk` / `WorkerRoleInfo::Feedback` to the +existing enums means a v1 peer (e.g. an old replay or storage worker +running an older binary) bincode-deserializing a `WireMessage::Worker` +that wraps the new variant will fail decode and **drop the entire +envelope**. Since `WorkerWireMessage::Request` is gossiped on +`_willow_workers` and addressed by `target_peer`, every subscribed +worker attempts decode — old workers simply log a warn and drop. This +is acceptable because: + +- Old workers wouldn't respond to a `Feedback` request anyway. +- Drops surface as warn-level decode errors, not outages. +- The new variants are addressed only at the feedback worker via + `target_peer`; cross-version chatter that's not feedback isn't + affected. + +If future variants need to coexist with strict-version peers, +`PROTOCOL_VERSION` (currently `1` in +`crates/transport/src/lib.rs:30`) gets bumped — but for v1 we +explicitly *do not* bump, since the old-worker drop behavior is +benign here and a version bump would force a coordinated upgrade of +every relay/replay/storage instance. + +**Reused gossip request path vs. dedicated encrypted ALPN.** During +brainstorming we initially proposed a new `/willow/feedback/0` ALPN +with direct iroh request/response. Reusing the gossip pathway keeps +v1 drastically simpler at the cost of feedback request payloads +being visible to other peers subscribed to `_willow_workers`. Since +v1's reports are destined for a public GitHub issue anyway *and* the +sensitive header data is salted-hashed before posting, that's an +acceptable trade-off. The encrypted ALPN is on the follow-up list. + +**Content moderation: pre-flight sanitization, not real-time +moderation.** Issues are filed into a public repository. The worker +applies fenced-code-block wrapping (defeats `@mentions`, autolinks, +markdown-image exfiltration, metadata-block spoofing), title +sanitization, length caps, per-peer + global rate limits, and +restart-throttling — but it does NOT run content classifiers, NOT +diff against an abuse blocklist, and NOT review submissions before +posting. The residual risk: a user can still post abusive prose that +GitHub's own policies might flag. Mitigations: + +- The `feedback:triage` label keeps reports out of the default + issue triage view until a maintainer has reviewed them. +- Maintainers can lock or delete issues via standard GitHub tools. +- The salted reporter handle (rotateable salt) supports incident + response without de-anonymizing legitimate reporters. +- The global rate limit (50/hour) bounds the rate of abusive + postings. + +If even one report ends up being a serious GitHub-ToS violation, the +operator can rotate the salt (resetting correlation), lower the +global cap, or take the worker offline (the UI then shows +`WorkerUnreachable` with the GitHub-direct fallback link). +Real-time moderation (private triage repo with manual promotion, +or model-based content filter) is on the follow-up list. + +**Identity rotation defeats per-peer rate limit.** Ed25519 keypairs +are free to generate; an attacker rotating identities bypasses the +per-peer 5/hour bucket. The global 50/hour cap is the real +abuse-bound. The per-peer bucket is defense-in-depth against a +single legitimate user accidentally over-submitting, not a +protection against motivated abuse. This is documented honestly +rather than overclaimed. + +**`FeedbackErrReason` reinvents `WireRejectReason`.** The existing +spec +[`2026-04-24-error-prefixes.md`](./2026-04-24-error-prefixes.md) +proposes a typed `WireRejectReason` enum (RateLimited, Invalid, +PermissionDenied, …) with the same semantics. We use a feedback-local +enum here to ship without depending on that spec landing first, but +the units and shape (`retry_after_ms`, `InvalidInput { field, message }`) +are aligned so consolidation is a pure refactor. Tracked in +[Follow-ups](#follow-ups). + ## Testing -- **`willow-common` unit tests:** round-trip the new - `WorkerRequest::Feedback`, `WorkerResponse::FeedbackOk` / - `FeedbackErr`, and `WorkerRoleInfo::Feedback` variants through - bincode and through the full `pack_wire` / `unpack_wire` path - (alongside the existing worker round-trip tests). -- **`willow-feedback` role tests:** exercise `FeedbackRole` directly - with a mock GitHub client trait — happy path, rate limit - enforcement, length validation, GitHub failure mapping, missing-token - `Unconfigured` path. No live HTTP. -- **`willow-feedback` HTTP unit tests:** the GitHub client module - parses representative GitHub API responses (success, 422 validation - error, 403 abuse detection, 404 repo not found) into - `FeedbackErrReason`. -- **`willow-client` test:** add to `crates/client/src/tests/`. Stand - up a mock worker via `MemNetwork`, send a feedback request, assert - the `Client::submit_feedback` future resolves to the issue URL. - Includes a rate-limit-mapping test and a worker-unreachable test. -- **`willow-web` browser test:** in `crates/web/tests/browser.rs`, - mount the settings page, open the feedback modal, fill the form, - assert the submit handler is called with the right `FeedbackRequest` - and that success/failure UI states render correctly. Use a stubbed - client (no real network). -- **No Playwright E2E for v1.** Per the project's testing policy, the - multi-peer scenarios this would cover (peer A submits, worker B - forwards) are exercised by the client-tier test against - `MemNetwork`. The only thing Playwright would add is "real iroh - transport," which we already cover for replay/storage and don't - need to re-cover here. +Per CLAUDE.md's "Which test tier to use" decision tree, push each +behavior to the lowest tier that covers it. Concretely: + +**`willow-common` (`cargo test -p willow-common`):** +- Round-trip `WorkerRequest::Feedback` (with and without + diagnostics; `Other { detail }` populated and `None`), + `WorkerResponse::FeedbackOk`, `WorkerResponse::FeedbackErr` (each + `FeedbackErrReason` variant), and `WorkerRoleInfo::Feedback` + through bincode AND through the full `pack_wire`/`unpack_wire` + envelope path. Mirrors the existing worker round-trip tests. +- Assert `WorkerRoleInfo::role_name()` returns `"feedback"` for the + new variant. + +**`willow-feedback` role tests (`just test-feedback`):** +- Exercise `FeedbackRole` directly with a mock GitHub-client trait. + No live HTTP. Cases: + - Happy path → `FeedbackOk { issue_url }`. + - Per-peer rate limit trips at the 6th request from the same peer + within the window; `retry_after_ms` returned. + - Global rate limit trips at the 51st request across distinct + peers; persists until the bucket refills. + - Length validation: title >200, body >8000, detail >60. + - Missing-token startup path returns `Unconfigured` for every + request. + - 401 from mock GitHub transitions the role to `Unconfigured` + permanently for the rest of the process. + - 403 with `x-ratelimit-remaining: 0` trips secondary-rate-limit + cooldown; subsequent requests get `RateLimited` with the + advertised `retry-after`. + - Idempotency cache: the same `(signer, dedup_id)` returns the + cached `issue_url` without contacting the mock. + - Sanitization: bodies containing closing fence sequences switch + to a longer fence; `@mentions` and image-exfiltration syntax + survive intact inside the fence (verified by string assertion). + - Reporter handle is salted hash, never raw peer ID. + - Logging: PAT, salt, user title, and user body do not appear in + captured `tracing` output for any path. + - Role does not implement `Debug`-printing the PAT (compile-time + plus a runtime test that calls a custom `Display` if one + exists). + +**`willow-feedback` GitHub client unit tests:** +- Parse representative GitHub API responses (201 created, 422 + validation, 401 unauthorized, 403 secondary-rate-limit, 404 repo + not found) into `FeedbackErrReason`. +- Verify the assembled issue body satisfies all sanitization + invariants for several adversarial inputs (closing fences, + Unicode chars, leading `[`). + +**`willow-client` tests (`just test-client`, in +`crates/client/src/tests/feedback.rs`):** +- Per CLAUDE.md, "Client API + derivation, no DOM" lives at the + client tier. Stand up a mock feedback worker via `MemNetwork`, + call `Client::submit_feedback`, and assert: + - Happy path resolves to a parsed `url::Url`. + - `dedup_id` is generated per call and reused on retry within the + same UI submission flow (verified by ferrying state through the + test fixture). + - `RateLimited` maps to `FeedbackError::RateLimited` with units + preserved (ms). + - `WorkerUnreachable` surfaces when the worker peer has no + listener. + - `NotConfigured` returned when `feedback_worker` is `None`. + - `BadIssueUrl` returned when the worker replies with a malformed + URL. + +**`willow-web` browser tests (`just test-browser`, in +`crates/web/tests/browser.rs`):** +- Per CLAUDE.md, "DOM rendering or event dispatch" lives at the + browser tier. Mount the settings page; verify: + - Feedback button renders inside the "Help & Feedback" section. + - Modal opens, validates input, disables submit when empty. + - "Other" category reveals the `detail` input. + - Diagnostics disclosure renders the exact `FeedbackDiagnostics` + that will be submitted. + - Each `FeedbackError` variant maps to the documented + failure-state copy (mock the client). + - "Send another" clears form *and* `dedup_id`; "Retry" preserves + both. + - With `__WILLOW_FEEDBACK_PEER_ID` unset, the form is disabled + and shows the GitHub-direct fallback link. + +**No Playwright E2E for v1.** The multi-peer scenario (peer A +submits, worker B forwards to GitHub) is covered at the client tier +against `MemNetwork`. The only thing Playwright would add is "real +iroh transport across two browsers," which is already exercised for +replay/storage in existing e2e specs and doesn't need to be +re-covered here. A *deferred* Playwright test that sends a feedback +report through the docker-compose stack with a stubbed GitHub server +is on the follow-up list — useful for catching docker/dev-plumbing +regressions, but not blocking v1. + +**Test commands (justfile):** + +```just +test-feedback: + cargo test -p willow-feedback + +# Folded into existing aggregate targets: +test-workers: test-replay test-storage test-feedback ... +``` ## Follow-ups These are explicitly **not** part of this spec but should land in the follow-ups list when v1 ships: -- Larger feedback redesign (the user has ideas requiring major +- Larger feedback redesign (maintainer has ideas requiring major refactoring — capture in a separate spec when ready). - Dedicated encrypted ALPN so feedback bodies are not visible to other workers-topic subscribers. -- Persistent rate-limit buckets (SQLite or shared with storage). +- Consolidate `FeedbackErrReason` into `WireRejectReason` once + [`2026-04-24-error-prefixes.md`](./2026-04-24-error-prefixes.md) + lands. Units are pre-aligned (ms); the migration is mechanical. +- Persistent rate-limit buckets (small JSON file or shared with + storage). +- Real-time content moderation: private triage repo with manual + promotion, or a model-based content filter, or an explicit + blocklist. Today's approach (sanitization + rate limit + GitHub + moderation) is honest defense-in-depth, not a moderation story. - Attachment support (recent log buffer, screenshot capture). - Two-way replies — the worker writes a comment-back path so a maintainer's GitHub reply lands in the reporter's UI. - Per-server feedback workers and per-server routing. - A "Send feedback" command-palette entry and a "?" top-bar shortcut once the form is proven. +- Playwright E2E covering the docker-compose feedback stack with a + stubbed GitHub server (catches dev-plumbing regressions). +- Promote `GITHUB_TOKEN` from compose `.env` to a real secrets + backend when v1 deployment moves beyond a single host. ## Open questions -None at spec time. Brainstorming resolved: +None at spec time. Brainstorming + round-1 review resolved: - Delivery target → GitHub issues via a project-run worker. - Transport → existing worker gossip request/response pathway (not a - new ALPN). + new ALPN); forward-compat behavior with v1 workers documented in + Trade-offs. - UI entry point → Settings page only, in v1. -- Rate limit → 5 / hour / peer. -- Anonymity → signed by the user's identity, peer ID included in the - issue as a pseudonymous handle; no display name. +- Per-peer rate limit → 5/hour; **global** rate limit → 50/hour + (the real abuse bound, given free identity rotation). +- Anonymity → signed by the user's identity, but the issue posts a + salted-hash reporter handle (rotateable salt for incident + response). No display name, no raw peer ID, no full UA string. +- Sanitization → user body wrapped in fenced code block (defeats + `@mention` spam, autolinks, image exfil, metadata spoofing). +- Idempotency → 16-byte client-generated `dedup_id` + worker LRU + cache. +- Trait change → `WorkerRole::handle_request` becomes async. + Migration impact: replay, storage, in-test roles each gain + `async fn`; no call sites change other than `.await`ing. From af9c525e8549f4df9d2b5a63f52fe7fab0d8e8c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 04:09:15 +0000 Subject: [PATCH 03/27] docs(spec): address round-2 review of feedback-system spec Round-2 parallel review surfaced several concrete implementation gaps that round-1's broad rewrite glossed over, plus tightening on sanitization and abuse-protection edge cases. Critical fixes: - Replace hand-wavy trunk static-file serving with concrete approach: copy crates/web/dev_assets/feedback-peer-id.txt into dist/ via a data-trunk copy-dir directive in index.html. - Add docker/web-entrypoint.sh that substitutes WILLOW_RELAY_URL and WILLOW_FEEDBACK_PEER_ID into the served init.js at container start. Replaces today's stock-nginx web Dockerfile (which had no entrypoint and required build-time relay URL injection). - Tighten body-fence sanitization to the precise CommonMark close-fence rule: scan with regex ^[ ]{0,3}`{N,}[ \t]*$, normalize CRLF, choose fence length max(3, N_max + 1). Adds adversarial test cases (tilde fences, indented fences, info-string tricks, HTML entities). - Restate body cap as <= 65,000 chars (not 60 KB bytes; GitHub counts codepoints). - Bump reporter handle hash from 6 bytes to 8 bytes; pin BIP-39 English wordlist (vendored) with 4 words from 44 bits + 5-hex suffix from remaining 20 bits; add salt-rotation runbook. - Restart-throttle uses O_CREAT|O_EXCL atomic check + fixed file path (/.feedback-last-boot inside the named volume); pair with compose `restart: on-failure:5` so wedged workers don't flap. - Add --generate-salt CLI flag (referenced in salt section but missing from flag table). Notable: - Length caps on FeedbackError::BadIssueUrl (512 chars) and FeedbackErrReason::InvalidInput { field: 64, message: 200 } to bound worker-supplied error formatting. - GitHub-direct fallback URL hardcoded to https://github.com/ with owner/repo regex validation at both worker startup AND web side (prevents javascript:/data: injection via mis-set FEEDBACK_REPO). - User-facing transport-privacy notice always rendered below Submit: "Your report is visible to Willow infrastructure peers in transit until E2EE ships." - Diagnostics non-mutation invariant + test: worker MUST post diagnostic fields byte-equal to those received; no server-side enrichment. - Cross-signer cache poisoning test: two distinct signers with same dedup_id get distinct issue URLs. - Logging-redaction test parameterized over every reachable code path (each FeedbackErrReason variant, cooldown, 401 transition, idempotency hit, panic path). - on_event no-op note clarifying async back-pressure is safe for FeedbackRole specifically. - Cite both state.rs:113 and heartbeat.rs:124 for trait migration (round-1 listed only one). - EndpointId::Display reference for bech32 peer ID format. - .env gitignore + PAT revocation runbook + blast-radius enumeration. - Exact justfile aggregate edit for test-workers. - Forward-compat: legacy clients dropping FeedbackOk responses is also benign (mirror case). --- .../2026-04-27-feedback-system-design.md | 418 +++++++++++++++--- 1 file changed, 348 insertions(+), 70 deletions(-) diff --git a/docs/specs/2026-04-27-feedback-system-design.md b/docs/specs/2026-04-27-feedback-system-design.md index 132d8e33..cdb09923 100644 --- a/docs/specs/2026-04-27-feedback-system-design.md +++ b/docs/specs/2026-04-27-feedback-system-design.md @@ -108,7 +108,9 @@ so the new wire types added there (see below) must remain WASM-clean. | `--github-repo` | `FEEDBACK_REPO` | yes (default: `intendednull/willow`) | `owner/repo` to file issues against | | `--rate-limit-per-hour` | — | optional, default 5 | Per-peer cap | | `--global-rate-limit-per-hour` | — | optional, default 50 | Worker-wide ceiling (see [Abuse](#abuse-protection-on-the-worker)) | +| `--reporter-salt-file` | — | optional, default `/etc/willow/feedback-salt` | 32-byte random salt for the reporter-handle hash (see [GitHub issue format](#github-issue-format)) | | `--generate-identity` | — | flag | Generate keypair at `--identity-path` and exit | +| `--generate-salt` | — | flag | Write 32 random bytes to `--reporter-salt-file` if missing and exit | | `--print-peer-id` | — | flag | Print the bech32 peer ID for `--identity-path` and exit (used by `just docker-ids`) | **PAT handling.** The PAT is wrapped in @@ -162,11 +164,29 @@ Async-trait is the simpler, smaller diff. The cost — pulling in `async-trait` (or using a manually-written `Pin>` return type) — is acceptable. +**Back-pressure scope.** The state-actor invariant is that one +message at a time is processed per actor instance. With async +`handle_request`, a slow GitHub call blocks the *feedback role's* +mailbox specifically — this is the right behavior because +`FeedbackRole::on_event` is a no-op (the feedback worker doesn't +track DAG state) and `heads_summaries` returns empty. For the +replay and storage roles, no message is ever slow enough to matter +(everything is in-memory or a small SQLite query), so the change +is observationally identical to today. The async trait is not +proposing per-actor concurrency — only the ability to `.await` +inside a handler. + **Migration impact.** Every `impl WorkerRole` must add `async` to -`handle_request` (replay, storage, the in-test `TestRole` in -`crates/worker/src/actors/state.rs:113`, and the in-test `TestSyncRole` -in `crates/worker/src/actors/sync.rs:108`). No call sites change other -than the actor's `.await`. +`handle_request`: + +- `crates/replay/src/role.rs:264` (ReplayRole) +- `crates/storage/src/role.rs:62` (StorageRole) +- `crates/worker/src/actors/state.rs:113` (TestRole, in tests) +- `crates/worker/src/actors/heartbeat.rs:124` (TestRole, in tests) +- `crates/worker/src/actors/sync.rs:108` (TestSyncRole, in tests) + +No call sites change other than the actor's `.await` on the role's +return value. ### Wire types in `willow-common` @@ -266,6 +286,9 @@ pub enum FeedbackErrReason { /// WireRejectReason design (docs/specs/2026-04-24-error-prefixes.md). /// Consolidating with that enum is on the follow-up list. RateLimited { retry_after_ms: u64 }, + /// `field` is bounded to 64 chars, `message` to 200 chars. + /// The worker enforces these caps before constructing the + /// reply; the client also enforces them on receipt. InvalidInput { field: String, message: String }, GithubFailure { status: u16, @@ -324,6 +347,8 @@ pub enum FeedbackError { InvalidInput { field: String, message: String }, #[error("github returned {status}: {message:?}")] GithubFailure { status: u16, message: Option }, + /// The inner string is the worker-supplied URL truncated to + /// 512 chars on receipt to bound error formatting. #[error("worker returned a malformed issue url: {0}")] BadIssueUrl(String), #[error("internal: {0}")] @@ -414,27 +439,54 @@ cleared / "Send another" is clicked. | `Internal(_)` | Inline error: "Something went wrong. Please retry." Logs full string to console. | The fallback "file directly on GitHub" link is always present in the -failure-state UI, hand-built with the user's title and body -url-encoded (`https://github.com/intendednull/willow/issues/new?title=...&body=...`). +failure-state UI. The URL is constructed in the web app from a +**hard-coded** prefix (`https://github.com/`) plus the configured +`FEEDBACK_REPO` value, plus url-encoded title and body: + +``` +https://github.com/{owner}/{repo}/issues/new?title={...}&body={...} +``` + +`{owner}/{repo}` is validated against the regex +`^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$` *both* at worker startup (the +worker refuses to start if `FEEDBACK_REPO` doesn't match) and on +the web side before constructing the URL. This prevents a +mis-configured `FEEDBACK_REPO` (e.g. `javascript:alert(1)`) from +becoming an XSS or open-redirect vector via the fallback link. Worst case, every path to feedback works. +Below the Submit button, the modal renders a small italic note: + +> Your report and any included diagnostic info are visible to +> Willow infrastructure peers in transit (the project relay and +> feedback worker) until end-to-end encryption ships. Don't include +> passwords, tokens, or other secrets. + +This is the user-facing acknowledgment of the unencrypted-transport +trade-off documented in [Trade-offs](#trade-offs). It shows +unconditionally — independent of whether the feedback worker is +configured — so users always know what the privacy posture is. + #### Configuration mechanism There is **no** existing web-app config for a worker peer ID — relay URL is the only externally configured peer in `crates/web/init.js` today. We add a parallel mechanism: -- New window global `__WILLOW_FEEDBACK_PEER_ID` (bech32 string), set by - `crates/web/init.js` (production: from env injected at container - start; dev: from the local-dev plumbing below). If unset, the web - app is "not configured" and renders the `NotConfigured` state above. +- New window global `__WILLOW_FEEDBACK_PEER_ID` set by + `crates/web/init.js`. The value is the bech32 form of an + `EndpointId` produced by `EndpointId::Display` (the format + defined in + [`docs/specs/2026-04-24-bech32-identifiers.md`](./2026-04-24-bech32-identifiers.md)). + Production: from env injected at container start; dev: from the + local-dev plumbing below. If unset, the web app is "not + configured" and renders the `NotConfigured` state above. - `crates/web/init.js` is already the place that picks up `__WILLOW_RELAY_URL` and falls back to localhost in dev; the - feedback peer ID follows the same pattern. For dev, when - `location.hostname` is `127.0.0.1` or `localhost`, init.js attempts - to fetch `/.dev/feedback-peer-id` (a static file served by - `trunk serve`) and assigns the result to - `__WILLOW_FEEDBACK_PEER_ID`. + feedback peer ID follows the same pattern via the production + entrypoint substitution and the dev `dev_assets/` fetch + described in [Local dev plumbing](#local-dev-plumbing) and + [Production peer ID injection](#production-peer-id-injection). - The web app reads the global at `Client` construction and stores the parsed `EndpointId` in `ClientConfig::feedback_worker`. @@ -463,35 +515,106 @@ this is the single most important sanitization step: Sanitization rules applied by the worker before assembly: -1. **Body wrapping.** User body is placed inside a `​```text` fenced - block. Before insertion, the worker verifies the body does not - contain a closing ` ``` ` sequence; if it does, the worker swaps - the fence to a longer backtick run (` ```` ` → ` ````` ` etc.) - that doesn't appear in the body. This neutralizes `@mention` - notification spam, autolinks, image-pixel exfiltration, and - metadata-block spoofing. -2. **Title sanitization.** Strip control chars, collapse internal - whitespace, escape leading `[` / `]`. Final title is +1. **Body wrapping.** User body is placed inside a backtick-fenced + markdown block with the `text` info-string. The fence length is + chosen to be longer than any backtick run inside the body, per the + CommonMark close-fence rule (§4.5): + + - Normalize line endings: `\r\n` → `\n` first. + - Scan body for any line matching the regex + `` ^[ ]{0,3}`{N,}[ \t]*$ `` (where N ≥ 3) — these are valid + closing fences for a backtick block. Track the maximum N + observed. + - Choose opening/closing fence length = `max(3, N_max + 1)`. + This guarantees no line in the body can close the fence. + - Backtick fences are *only* closed by backtick fences (and + vice-versa for tilde), so tilde-fenced content (`~~~`) inside + the body is inert and needs no special handling. + - HTML entities like ``` are rendered as text (not parsed) + inside any fenced code block, so they don't escape the fence. + + This neutralizes `@mention` notification spam, autolinks, + markdown-image exfiltration, and metadata-block spoofing — the + core threats the round-1 review identified. A unit test exercises + adversarial inputs: closing-fence sequences of varying length, + indented fences (1–3 leading spaces), CRLF mix, info-string + tricks, raw HTML payloads (``), and HTML + entity-encoded backticks. + +2. **Title sanitization.** Strip ASCII control chars (0x00–0x1F, + 0x7F), strip Unicode bidi/RTL override codepoints (U+202A–U+202E, + U+2066–U+2069), collapse internal whitespace to single spaces, + escape leading `[` / `]` with backslashes. Final title is `[Bug] ` / `[Suggestion] ` / - `[Other:] ` (the `` segment is also - sanitized). + `[Other:] ` (the `` segment goes + through the same sanitizer). Cap final title at 250 chars (200 + user + ~50 worker overhead). + 3. **Total body cap.** After assembly, the worker asserts the - composed issue body is `<= 60 KB` (well under GitHub's 65 KB - limit). Over-cap is a worker-side bug, not a user-facing error; - reject with `Internal`. + composed issue body is **≤ 65,000 chars** (GitHub's documented + issue-body limit is 65,536 chars; we leave a small safety margin). + Note this is a *char* count, not a byte count — GitHub counts + Unicode codepoints. Over-cap is a worker-side assembly bug + (worst-case input is 8,000 body + 60 detail + ~500 metadata = + well under 65,000), not a user-facing error; over-cap rejects + with `FeedbackErrReason::Internal` after logging. **Reporter handle.** The peer ID is **not** posted in cleartext. The worker computes -`hash = blake3(worker_salt || peer_id_bytes)[..6]` and renders it -as a [bip39-style] human-friendly four-word phrase + 4-hex suffix -(`whisper-quiet-fern-3a9c`). The salt is loaded from -`--reporter-salt-file` (default: `/etc/willow/feedback-salt`, -generated on first run with `--generate-salt`). This: +`hash = blake3(worker_salt || peer_id_bytes)[..8]` (8 bytes = 64 +bits — bumped from 6 bytes per round-2 review to put targeted +second-preimage attacks beyond practical reach for an open-source +project's threat model) and renders it as a deterministic 4-word +phrase plus 4-hex suffix (`whisper-quiet-fern-3a9c`): + +- **Wordlist:** the BIP-39 English wordlist (2048 words, 11 bits + each) via the existing-in-ecosystem `bip39 = "2"` crate. v1 + vendors the wordlist into `crates/feedback/src/wordlist.rs` as a + static array to avoid pulling the full bip39 dep tree (we only + need the words, not bip39's mnemonic checksum). +- **Mapping:** take 11 bits at a time from the 64-bit hash → 4 + words consume 44 bits; the remaining 20 bits are rendered as a + 5-hex-char suffix, *not* 4 — fix to the 4-hex suffix written in + the example above. (Round-2 review caught a bit-arithmetic + mismatch.) Final form is `word-word-word-word-NNNNN` (lowercase, + hyphens, 5 hex). + +This: - Lets maintainers correlate multiple reports from the same user without exposing the public Ed25519 key that signs the user's state-DAG events on every Willow server they participate in. -- Rotating the salt resets all correlation, which is the right - knob for incident response. +- The handle is a **display** aid, not an authenticator. The spec + is explicit that maintainers MUST NOT take punitive action based + on handle-match alone; the salted hash is a triage ergonomics + tool, not an identity claim. +- Rotating the salt resets all correlation. This is the + incident-response knob. + +**Salt file.** Stored at `--reporter-salt-file` (default +`/etc/willow/feedback-salt`, which lives inside the +`feedback-identity` named docker volume so it survives container +restarts). The file is 32 random bytes; `--generate-salt` (added to +the CLI flag table) writes one if missing and exits. The +`feedback-entrypoint.sh` script runs `--generate-identity` and +`--generate-salt` if the corresponding files are missing, before +starting the worker. + +**Salt rotation runbook.** When malicious reports require breaking +correlation across a window (or for routine hygiene): + +```sh +docker compose exec feedback rm /etc/willow/feedback-salt +docker compose restart feedback +``` + +The entrypoint regenerates the salt; all subsequent reports get +fresh handles. The idempotency cache (`(signer, dedup_id) → url`) +is also flushed on restart (it's in-memory) — this avoids the case +where a pre-rotation cache entry leaks the old handle in a retry +response. Maintainers lose the ability to correlate reports across +the rotation boundary; this is the documented trade-off for being +able to break a sustained abuse pattern without coordinating with +GitHub support. **Labels.** `feedback`, plus one of `feedback:bug`, `feedback:suggestion`, `feedback:other`, plus @@ -504,6 +627,16 @@ the UI controls `diagnostics: Option` directly). With diagnostics omitted, the metadata block shows `(diagnostics not provided)` after the category line. +**Diagnostics non-mutation invariant.** The worker MUST post +diagnostic field values byte-equal to those received in the +request — no server-side enrichment (no GeoIP, no reverse-DNS, no +synthesized fields). The disclosure-UI promise is "what you see is +what is sent"; this invariant makes that promise enforceable. The +worker's posting code is implemented as a pure render of +`(diagnostics, sanitized_body, sanitized_title, hashed_handle)` and +a unit test asserts each diagnostic field appears verbatim in the +posted markdown for several inputs. + ### Abuse protection on the worker - **Per-peer rate limit:** in-memory token bucket keyed by signer @@ -515,11 +648,31 @@ With diagnostics omitted, the metadata block shows identities). The global cap turns the attack from "unbounded spam" into "saturate the worker's bucket and stop." Operators can raise/lower at runtime via the CLI flags. -- **Restart-loop hardening:** worker process supervisor (Docker - restart policy) uses exponential-backoff restart, and the worker - refuses to start more than once per 15 seconds (a tiny gating - file under the identity dir, touched on startup). This bounds - rate-limit reset abuse without needing persistent buckets. +- **Restart-loop hardening:** the worker writes a tiny gating file + at a fixed path inside the named identity volume — + specifically `/.feedback-last-boot` + (e.g. `/etc/willow/.feedback-last-boot` for the default identity + path). On startup, the worker: + + 1. Opens the file with `O_CREAT | O_EXCL` → if creation + succeeds, this is the first boot ever, write `now()` and + proceed. + 2. If creation fails because the file exists: read its mtime, + compute `delta = now() - mtime`. If `delta < 15 seconds`, + `sleep(15 - delta)`. Then update the file's mtime + (`utime(...)` or rewrite contents) atomically and proceed. + 3. If the file is missing at read time (unlikely race with step + 1's open), treat as `delta = 15s` and proceed. + + The gating file lives inside the docker `feedback-identity` + volume, so it survives container restarts and is invisible from + outside the volume mount. Operators who deliberately recreate the + volume reset the throttle along with the identity — that's + intended behavior. Compose is configured with + `restart: on-failure` (not `restart: always`) and a + `max_attempts` of 5 within a window so a wedged worker doesn't + flap forever; once `max_attempts` is exceeded, the operator + inspects logs and decides. - **Length and shape validation:** title 1..=200, body 1..=8000, detail 0..=60, dedup_id length, diagnostics field caps. Rejection is `InvalidInput` *before* contacting GitHub. @@ -571,14 +724,30 @@ risk are documented explicitly in [Trade-offs](#trade-offs). - RUST_LOG=info,willow_feedback=debug volumes: - feedback-identity:/etc/willow + restart: on-failure:5 # bounded retry, paired with the + # 15-second startup throttle inside + # the worker (see Abuse protection) volumes: feedback-identity: ``` `GITHUB_TOKEN` is loaded from `.env` (which `docker-compose` reads - natively); `.env` is `.gitignore`-d. There is no `secrets:` block - because v1 targets a single-host docker-compose deployment; promoting - to a real secrets backend is a follow-up. + natively); `.env` MUST be in `.gitignore` (already true for the + repo, but a precommit / CI guard rejecting staged `.env` is added + to keep it that way). There is no `secrets:` block because v1 + targets a single-host docker-compose deployment; promoting to a + real secrets backend is a follow-up. + + **Blast radius if the PAT leaks** (e.g. accidental `.env` + commit): an attacker can file or edit issues on the configured + `FEEDBACK_REPO` until the PAT is revoked. They cannot push code, + read private repos, or affect any other GitHub account asset + because the PAT is fine-grained and scoped to `Issues: write` on + one repo. **Revocation runbook**: rotate the PAT in GitHub's + fine-grained tokens UI, update `.env`, run + `docker compose restart feedback`. The worker will surface + `Unconfigured` if it sees a 401 in the meantime, and recover + automatically once the new PAT is in place. **Justfile additions:** @@ -590,31 +759,118 @@ risk are documented explicitly in [Trade-offs](#trade-offs). **Local dev plumbing.** `scripts/dev.sh` already manages relay, replay, and storage workers under `.dev/`. The feedback worker is -added analogously: - -1. On first run, `scripts/dev.sh` invokes - `cargo run -p willow-feedback -- --identity-path .dev/feedback.key - --generate-identity` if the keypair is absent. -2. Then `cargo run -p willow-feedback -- --identity-path - .dev/feedback.key --print-peer-id > .dev/feedback-peer-id`. -3. The dev web app is served from a directory that includes - `.dev/feedback-peer-id` as `/.dev/feedback-peer-id` (configure - trunk's `--ignore` / static dir as needed; the simplest option is - to add a `dev_assets/` symlink). `crates/web/init.js` fetches it - on dev hostnames and assigns to `__WILLOW_FEEDBACK_PEER_ID`. -4. The dev feedback worker runs **without** a `GITHUB_TOKEN`. It - accepts and validates requests fully, but every successful path - replies `FeedbackErr { reason: Unconfigured }` instead of touching +added analogously, with explicit first-run idempotency: + +1. **Identity keypair generation** (run unconditionally; the + command no-ops if the file exists): + + ```sh + if [ ! -f .dev/feedback.key ]; then + cargo run -q -p willow-feedback -- \ + --identity-path .dev/feedback.key \ + --generate-identity + fi + ``` + +2. **Print peer ID into a file the dev web build can serve:** + + ```sh + cargo run -q -p willow-feedback -- \ + --identity-path .dev/feedback.key --print-peer-id \ + > crates/web/dev_assets/feedback-peer-id.txt + ``` + + The `crates/web/dev_assets/` directory is checked-in (with + `.gitignore` excluding the generated `feedback-peer-id.txt` + contents) and referenced from `crates/web/index.html` via: + + ```html + + ``` + + This causes `trunk serve` to copy the directory into `dist/`, + making `/dev_assets/feedback-peer-id.txt` reachable at the served + URL `http://localhost:8080/dev_assets/feedback-peer-id.txt`. This + is the *concrete* mechanism that replaces the round-1 hand-wave + about trunk static-file serving — `trunk serve` has no + `--static-dir` flag, so the only supported path is via + `data-trunk` directives in `index.html`. + + In production builds the directive is still present, but the + directory is empty (or contains a stub), so no production peer ID + is ever served from this path. Production injection happens via + the entrypoint described below. + +3. **Web app fetch in dev:** `crates/web/init.js` already special-cases + localhost for the relay URL. Add an analogous fetch for the + feedback peer ID: + + ```javascript + if (!window.__WILLOW_FEEDBACK_PEER_ID && (h === '127.0.0.1' || h === 'localhost')) { + fetch('/dev_assets/feedback-peer-id.txt') + .then(r => r.ok ? r.text() : '') + .then(s => { window.__WILLOW_FEEDBACK_PEER_ID = s.trim(); }) + .catch(() => {}); + } + ``` + + The fetch is fire-and-forget; if it fails, the app renders the + `NotConfigured` state (with the GitHub-direct fallback link). + +4. **Dev worker runs without `GITHUB_TOKEN`.** It accepts and + validates requests fully but every successful path replies + `FeedbackErr { reason: Unconfigured }` instead of touching GitHub. This exercises every UI path end-to-end (idempotency cache, rate limit, sanitization, error surfaces) without leaking a real PAT into local environments. -**Production peer ID injection.** `docker/web.Dockerfile` does not -own the feedback peer ID — the deployment's web container entrypoint -reads `WILLOW_FEEDBACK_PEER_ID` from the environment and injects it -into the served `init.js` at startup (a tiny `sed` step in the -existing entrypoint, mirroring how the relay URL is injected today). -If unset, the form renders `NotConfigured`. +**Production peer ID injection.** Today's `docker/web.Dockerfile` is +stock `nginx:alpine` with no entrypoint script — and not just for +feedback: the relay URL has the same gap (it's currently set at +build time). v1 introduces `docker/web-entrypoint.sh` to fix both: + +```sh +#!/bin/sh +set -e + +INIT_JS=/usr/share/nginx/html/init.js + +# Substitute env-injected values into the served init.js. Both vars +# are optional: if unset, the placeholder remains and the web app +# treats the corresponding feature as not-configured. +if [ -n "$WILLOW_RELAY_URL" ]; then + sed -i "s|__INJECT_RELAY_URL__|$WILLOW_RELAY_URL|g" "$INIT_JS" +fi +if [ -n "$WILLOW_FEEDBACK_PEER_ID" ]; then + sed -i "s|__INJECT_FEEDBACK_PEER_ID__|$WILLOW_FEEDBACK_PEER_ID|g" "$INIT_JS" +fi + +exec nginx -g 'daemon off;' +``` + +`crates/web/init.js` is updated to use the placeholders: + +```javascript +window.__WILLOW_RELAY_URL = window.__WILLOW_RELAY_URL || "__INJECT_RELAY_URL__"; +window.__WILLOW_FEEDBACK_PEER_ID = window.__WILLOW_FEEDBACK_PEER_ID || "__INJECT_FEEDBACK_PEER_ID__"; +// If the placeholder survived (env not set in production), null it out. +if (window.__WILLOW_RELAY_URL === "__INJECT_RELAY_URL__") delete window.__WILLOW_RELAY_URL; +if (window.__WILLOW_FEEDBACK_PEER_ID === "__INJECT_FEEDBACK_PEER_ID__") delete window.__WILLOW_FEEDBACK_PEER_ID; +``` + +`docker/web.Dockerfile` is updated to: + +```dockerfile +COPY docker/web-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh +ENTRYPOINT ["/docker-entrypoint.sh"] +``` + +If `WILLOW_FEEDBACK_PEER_ID` is unset at container start, the form +renders `NotConfigured`. The same mechanism cleans up the +relay-URL build-time-injection drift that exists today; the +relay-URL part is in scope for this spec because we're touching +`init.js` and `web.Dockerfile` already. ### Observability @@ -644,8 +900,14 @@ Log fields: - `latency_ms` — total request latency. The PAT, the salt, the user body, and the user title are **never** -logged. A unit test asserts none of these strings appear in any log -output the role emits during a happy-path or error-path call. +logged. A unit test parameterized over every reachable code path +asserts that none of those four strings (each set to a distinct +unique sentinel) appears in captured `tracing` output. Paths +covered: happy path, every `FeedbackErrReason` variant, the +secondary-rate-limit cooldown, the 401 → permanent-Unconfigured +transition, the idempotency-cache hit, and a deliberately-panicking +mock-GitHub path (panics surface as `tracing::error!`, which the +test also captures). ## Trade-offs @@ -696,6 +958,10 @@ is acceptable because: - The new variants are addressed only at the feedback worker via `target_peer`; cross-version chatter that's not feedback isn't affected. +- The mirror case — a v1 client receiving a + `WorkerResponse::FeedbackOk` it doesn't know about — is also + benign: only feedback-aware clients send the request, so v1 + clients never receive responses for requests they didn't send. If future variants need to coexist with strict-version peers, `PROTOCOL_VERSION` (currently `1` in @@ -788,7 +1054,10 @@ behavior to the lowest tier that covers it. Concretely: cooldown; subsequent requests get `RateLimited` with the advertised `retry-after`. - Idempotency cache: the same `(signer, dedup_id)` returns the - cached `issue_url` without contacting the mock. + cached `issue_url` without contacting the mock; two distinct + signers with the *same* `dedup_id` get distinct issue URLs (no + cross-signer cache poisoning — guards a future refactor that + might accidentally drop `signer` from the cache key). - Sanitization: bodies containing closing fence sequences switch to a longer fence; `@mentions` and image-exfiltration syntax survive intact inside the fence (verified by string assertion). @@ -852,14 +1121,23 @@ regressions, but not blocking v1. **Test commands (justfile):** +Add a new `test-feedback` target alongside `test-replay` / +`test-storage` and append `-p willow-feedback` to the existing +`test-workers` aggregate (current line passes +`-p willow-worker -p willow-replay -p willow-storage -p willow-common`): + ```just test-feedback: cargo test -p willow-feedback -# Folded into existing aggregate targets: -test-workers: test-replay test-storage test-feedback ... +test-workers: + cargo test -p willow-worker -p willow-replay -p willow-storage -p willow-feedback -p willow-common ``` +Also append the feedback service to `just docker-ids` so the script +prints all four worker peer IDs (relay/replay/storage/feedback) in +one go. + ## Follow-ups These are explicitly **not** part of this spec but should land in the From 4c4b82bbc35dd653b8a43ee4111ceb9365e6bd4e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 04:13:30 +0000 Subject: [PATCH 04/27] docs(spec): polish round-3 verifier nits in feedback-system spec - Update reporter-handle examples to match the 5-hex suffix the bit-arithmetic spec calls for (previously showed 4-hex 3a9c which contradicted the 44-bit + 20-bit split). - Remove duplicate "Reused gossip request path" trade-off paragraph (residue of round-2 editing); keep the better-positioned version that follows Forward compatibility. - Replace utime() portability hand-wave with concrete advice: tempfile + rename, or filetime::set_file_mtime. --- .../2026-04-27-feedback-system-design.md | 39 ++++++++----------- 1 file changed, 16 insertions(+), 23 deletions(-) diff --git a/docs/specs/2026-04-27-feedback-system-design.md b/docs/specs/2026-04-27-feedback-system-design.md index cdb09923..00f22b64 100644 --- a/docs/specs/2026-04-27-feedback-system-design.md +++ b/docs/specs/2026-04-27-feedback-system-design.md @@ -497,7 +497,7 @@ user-supplied body is wrapped in a fenced markdown code block** — this is the single most important sanitization step: ````markdown -**Reporter (salted hash):** `whisper-quiet-fern-3a9c` +**Reporter (salted hash):** `whisper-quiet-fern-3a9cf` **Category:** Bug **App version:** 0.1.0 **Build:** abc1234 @@ -565,7 +565,7 @@ The worker computes bits — bumped from 6 bytes per round-2 review to put targeted second-preimage attacks beyond practical reach for an open-source project's threat model) and renders it as a deterministic 4-word -phrase plus 4-hex suffix (`whisper-quiet-fern-3a9c`): +phrase plus 5-hex suffix (`whisper-quiet-fern-3a9cf`): - **Wordlist:** the BIP-39 English wordlist (2048 words, 11 bits each) via the existing-in-ecosystem `bip39 = "2"` crate. v1 @@ -659,8 +659,11 @@ posted markdown for several inputs. proceed. 2. If creation fails because the file exists: read its mtime, compute `delta = now() - mtime`. If `delta < 15 seconds`, - `sleep(15 - delta)`. Then update the file's mtime - (`utime(...)` or rewrite contents) atomically and proceed. + `sleep(15 - delta)`. Then bump the file's mtime via the + standard pattern of writing fresh contents to a sibling + tempfile and `rename()`-ing over the original (atomic on + POSIX, the `filetime::set_file_mtime` crate works too). + Proceed. 3. If the file is missing at read time (unlikely race with step 1's open), treat as `delta = 15s` and proceed. @@ -911,19 +914,6 @@ test also captures). ## Trade-offs -**Reused gossip request path vs. dedicated encrypted ALPN.** During -brainstorming we initially proposed a new `/willow/feedback/0` ALPN -with direct iroh request/response. Inspecting the existing worker -infrastructure showed that replay and storage already share a single -gossip-based request/response pathway (`_willow_workers` topic, -`WorkerWireMessage::Request/Response`). Reusing that pathway keeps v1 -drastically simpler — no new transport code, no new dispatcher, no -parallel correlation logic — at the cost of feedback request payloads -being visible to other peers subscribed to `_willow_workers`. Since -v1's reports are destined for a public GitHub issue anyway, that's an -acceptable trade-off for the initial cut. A dedicated encrypted ALPN -is on the follow-up list with the broader feedback redesign. - **In-memory rate limit vs. persistent.** A restart resets every peer's bucket. For a single instance with light load this is fine — combined with the global cap and the 15-second restart-throttle, @@ -972,12 +962,15 @@ every relay/replay/storage instance. **Reused gossip request path vs. dedicated encrypted ALPN.** During brainstorming we initially proposed a new `/willow/feedback/0` ALPN -with direct iroh request/response. Reusing the gossip pathway keeps -v1 drastically simpler at the cost of feedback request payloads -being visible to other peers subscribed to `_willow_workers`. Since -v1's reports are destined for a public GitHub issue anyway *and* the -sensitive header data is salted-hashed before posting, that's an -acceptable trade-off. The encrypted ALPN is on the follow-up list. +with direct iroh request/response. Reusing the existing gossip +pathway (`_willow_workers` topic, +`WorkerWireMessage::Request/Response`) keeps v1 drastically simpler +— no new transport code, no new dispatcher, no parallel correlation +logic — at the cost of feedback request payloads being visible to +other peers subscribed to `_willow_workers`. Since v1's reports are +destined for a public GitHub issue anyway *and* the sensitive +header data is salted-hashed before posting, that's an acceptable +trade-off. The encrypted ALPN is on the follow-up list. **Content moderation: pre-flight sanitization, not real-time moderation.** Issues are filed into a public repository. The worker From f02c82e45d9fd6b4e8d66adf63937361cd718610 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 04:16:40 +0000 Subject: [PATCH 05/27] docs(spec): address fresh-eyes review of feedback-system spec Fresh-eyes round-3 reviewer found one critical issue and a handful of notable gaps. Addressed: - Critical: WorkerRole::handle_request had no signer parameter, but the spec said the role recovers signer from unpack_wire. Trait signature now explicitly takes `signer: EndpointId`; actor pulls it from the (WireMessage, EndpointId) tuple end-to-end. Replay and storage roles ignore the parameter. - Pin async-trait crate (was: "or hand-rolled Pin>"). - Worst-case title math was wrong: [Other:<60>] is 71-char prefix, not ~50, so cap goes from 250 to 280 with truncation-with-... if GitHub's 256 ceiling is exceeded. - Worker HTTP timeout (20s) explicitly bounded below client's 30s total, with honest documentation of the rare slow-then-retry duplicate-issue race and a follow-up to dedup via GitHub search. - Pin token bucket refill semantics: continuous refill (5/3600s for per-peer, 50/3600s global), retry_after_ms is the precise time to next available token. - Spell out crates/web/dev_assets/ contents (.gitignore content, .gitkeep, exact index.html insertion point near the existing data-trunk copy-file directive). - Verify trunk does not asset-hash copy-file outputs, so hardcoded /usr/share/nginx/html/init.js path in entrypoint is safe; CI smoke test for placeholders. - Cache-busting via __INJECT_BUILD_TAG__ placeholder in `) plus a +third entrypoint substitution (`WILLOW_BUILD_TAG`) is added in +this spec. The dev path uses the same mechanism with +`WILLOW_BUILD_TAG=dev`, which is fine because dev refreshes are +manual. + ### Observability Per-request logging is critical for debugging "user X says they @@ -1064,10 +1153,18 @@ behavior to the lowest tier that covers it. Concretely: **`willow-feedback` GitHub client unit tests:** - Parse representative GitHub API responses (201 created, 422 validation, 401 unauthorized, 403 secondary-rate-limit, 404 repo - not found) into `FeedbackErrReason`. + not found) into `FeedbackErrReason`. JSON fixtures live in + `crates/feedback/tests/fixtures/github/` (one file per status + code) and are recorded straight from + [GitHub's REST docs](https://docs.github.com/en/rest/issues/issues#create-an-issue) + / a one-time real call captured via `curl`. Implementer captures + these as part of the initial test PR. - Verify the assembled issue body satisfies all sanitization invariants for several adversarial inputs (closing fences, - Unicode chars, leading `[`). + indented fences, tilde fences, CRLF mixes, info-string tricks, + HTML entity-encoded backticks, RTL override codepoints, leading + `[`, and a body that's exactly the closing-fence-free worst case + of 8000 chars of backticks). **`willow-client` tests (`just test-client`, in `crates/client/src/tests/feedback.rs`):** From 8f2fe3afa89c8d45b5d89746d444058d8f776467 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 05:40:10 +0000 Subject: [PATCH 06/27] docs(plan): scaffolding + Phase 1 Task 1.1 (wire-type leaf types) --- docs/plans/2026-04-27-feedback-system.md | 219 +++++++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 docs/plans/2026-04-27-feedback-system.md diff --git a/docs/plans/2026-04-27-feedback-system.md b/docs/plans/2026-04-27-feedback-system.md new file mode 100644 index 00000000..242b6f59 --- /dev/null +++ b/docs/plans/2026-04-27-feedback-system.md @@ -0,0 +1,219 @@ +# Feedback System Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship an in-app "Send Feedback" form in the Willow web UI that submits to a new `willow-feedback` worker node, which proxies user-submitted feedback to GitHub issues on `intendednull/willow`. + +**Architecture:** A new native-only worker crate (`willow-feedback`) joins the existing worker pattern alongside replay and storage, reusing the gossip-based `WorkerWireMessage::Request/Response` pathway. The `WorkerRole::handle_request` trait method becomes `async` and gains a `signer: EndpointId` parameter so the role can enforce per-peer rate limits and compute the salted reporter handle. The web UI gets a new modal under Settings, configured via a `__WILLOW_FEEDBACK_PEER_ID` window global injected at container start by a new `docker/web-entrypoint.sh`. + +**Tech Stack:** Rust (workspace), Leptos (web UI), `reqwest`+`rustls-tls` (GitHub API), `secrecy` (PAT handling), `blake3` (reporter-handle hash), `async-trait` (trait change), `bincode` over iroh-gossip (transport), `trunk` (web build), Docker Compose (deployment). + +**Spec:** [`docs/specs/2026-04-27-feedback-system-design.md`](../specs/2026-04-27-feedback-system-design.md). + +--- + +## File structure + +**New files (created in this plan):** + +``` +crates/feedback/ — new worker crate +├── Cargo.toml +├── build.rs — emits WILLOW_BUILD_SHA env at compile time +├── src/ +│ ├── main.rs — CLI parsing, IrohNetwork bring-up, runtime::run +│ ├── role.rs — FeedbackRole : WorkerRole (sanitization, rate limits, idempotency) +│ ├── github.rs — reqwest-based GitHub-issues client + GithubClient trait +│ ├── handle.rs — salted-hash reporter handle (blake3 + BIP-39) +│ ├── wordlist.rs — vendored BIP-39 English wordlist (2048 entries) +│ ├── ratelimit.rs — token-bucket per-peer + global +│ ├── sanitize.rs — body-fence + title sanitization +│ ├── throttle.rs — startup-throttle file gating +│ └── salt.rs — load-or-generate 32-byte salt file +└── tests/ + └── fixtures/github/ — recorded GitHub API JSON responses + ├── 201-created.json + ├── 401-unauthorized.json + ├── 403-secondary-rate-limit.json + ├── 404-not-found.json + └── 422-validation.json + +docker/ +├── feedback.Dockerfile — sibling to docker/replay.Dockerfile +├── feedback-entrypoint.sh — sibling to docker/replay-entrypoint.sh +└── web-entrypoint.sh — new: substitutes __INJECT_*__ placeholders in init.js + +crates/web/dev_assets/ — checked-in directory for trunk copy-dir +├── .gitignore — single line: feedback-peer-id.txt +└── .gitkeep — empty placeholder + +crates/client/src/feedback.rs — Client::submit_feedback + FeedbackError +crates/client/src/tests/feedback.rs — client-tier integration tests via MemNetwork + +crates/web/src/components/feedback.rs — modal + failure-state copy +``` + +**Modified files:** + +``` +crates/common/src/worker_types.rs — extend WorkerRequest/Response/RoleInfo, add wire types +crates/worker/src/actors/mod.rs — WorkerRequestMsg gains signer +crates/worker/src/actors/state.rs — handler awaits role; pass signer through +crates/worker/src/actors/network.rs — pass requester (signer) into WorkerRequestMsg +crates/worker/src/actors/sync.rs — internal Sync requests need a signer (use local peer) +crates/worker/src/actors/heartbeat.rs — TestRole becomes async + takes _signer +crates/worker/Cargo.toml — add async-trait dep +crates/replay/src/role.rs — async fn handle_request(_signer, ...) +crates/storage/src/role.rs — async fn handle_request(_signer, ...) +crates/client/src/lib.rs — pub mod feedback; ClientConfig::feedback_worker +crates/client/Cargo.toml — add `url` dep +crates/web/src/components/settings.rs — Help & Feedback section + button +crates/web/src/components/mod.rs — pub mod feedback +crates/web/src/state.rs — read __WILLOW_FEEDBACK_PEER_ID, store in client config +crates/web/index.html — add `` +crates/web/init.js — placeholder substitution + dev fetch +docker/web.Dockerfile — COPY web-entrypoint.sh; ENTRYPOINT +docker-compose.yml — new `feedback` service + volume +scripts/dev.sh — start feedback worker, write peer ID to dev_assets +justfile — test-feedback, build-feedback, docker-ids, test-workers +Cargo.toml — add `willow-feedback` to workspace members +``` + +--- + +## Phase 1: Wire types + async trait change + +**Why first:** the trait change is foundational. Replay and storage compile and test against it. If we don't land it cleanly first, every other phase blocks. We do this with TDD: write the new wire-type round-trip tests, watch them fail, add the variants, watch them pass, then migrate the trait. + +**Note on workspace registration:** the new `willow-feedback` crate is registered in the workspace `Cargo.toml` as part of Phase 2 (when the crate directory actually exists). Phase 1 only touches `willow-common`, `willow-worker`, `willow-replay`, and `willow-storage`. + +### Task 1.1: Add `FeedbackCategory`, `ClientPlatform`, `FeedbackDiagnostics` types + +**Files:** +- Modify: `crates/common/src/worker_types.rs` + +These three types are leaf types (no dependencies on the other new variants), so we add them and their round-trip tests first. + +- [ ] **Step 1: Write the failing tests** + +Append to the `#[cfg(test)] mod tests` block at the bottom of `crates/common/src/worker_types.rs`: + +```rust +#[test] +fn feedback_category_round_trips() { + for cat in [ + FeedbackCategory::Bug, + FeedbackCategory::Suggestion, + FeedbackCategory::Other { detail: None }, + FeedbackCategory::Other { + detail: Some("performance".to_string()), + }, + ] { + let bytes = bincode::serialize(&cat).unwrap(); + let decoded: FeedbackCategory = bincode::deserialize(&bytes).unwrap(); + assert_eq!(cat, decoded); + } +} + +#[test] +fn client_platform_round_trips() { + for cp in [ + ClientPlatform::Web { + ua_family: "firefox/138".to_string(), + }, + ClientPlatform::Native { + os: "linux".to_string(), + arch: "x86_64".to_string(), + }, + ] { + let bytes = bincode::serialize(&cp).unwrap(); + let decoded: ClientPlatform = bincode::deserialize(&bytes).unwrap(); + assert_eq!(cp, decoded); + } +} + +#[test] +fn feedback_diagnostics_round_trips() { + let diag = FeedbackDiagnostics { + app_version: "0.1.0".to_string(), + build_hash: Some("abc1234".to_string()), + locale: Some("en-US".to_string()), + client: ClientPlatform::Web { + ua_family: "firefox/138".to_string(), + }, + }; + let bytes = bincode::serialize(&diag).unwrap(); + let decoded: FeedbackDiagnostics = bincode::deserialize(&bytes).unwrap(); + assert_eq!(diag, decoded); +} +``` + +- [ ] **Step 2: Run tests, verify they fail with "not found" errors** + +Run: `cargo test -p willow-common feedback_category_round_trips client_platform_round_trips feedback_diagnostics_round_trips` +Expected: COMPILE FAIL — `cannot find type FeedbackCategory in this scope` (and similar for the other two types). + +- [ ] **Step 3: Add the types** + +Append to `crates/common/src/worker_types.rs` *above* the `#[cfg(test)] mod tests` block: + +```rust +/// Top-level category for a feedback report. Surfaced as a label and +/// title prefix on the GitHub issue. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[non_exhaustive] +pub enum FeedbackCategory { + Bug, + Suggestion, + /// Free-form category. `detail` is a short subcategory string the + /// user types (e.g. "performance", "docs"); shown in the issue + /// title prefix as `[Other:]`. + Other { + /// Optional, <= 60 chars. Validated by the worker. + detail: Option, + }, +} + +/// The submitting client's platform — coarse-grained on purpose so +/// the issue body cannot include a fingerprintable full UA string. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[non_exhaustive] +pub enum ClientPlatform { + /// Browser submission. `ua_family` is `"/"`, + /// e.g. `"firefox/138"`. <= 40 chars. + Web { ua_family: String }, + /// Native submission. `"linux"` / `"macos"` / `"windows"` and + /// e.g. `"x86_64"` / `"aarch64"`. + Native { os: String, arch: String }, +} + +/// Optional diagnostic info attached to a feedback report. Only +/// included when the user opts in via the UI checkbox; the disclosure +/// renders the *exact* value that will be sent. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[non_exhaustive] +pub struct FeedbackDiagnostics { + /// `CARGO_PKG_VERSION` of the submitting client. + pub app_version: String, + /// Short git SHA from `option_env!("WILLOW_BUILD_SHA")` injected + /// by `build.rs`. None in dev builds. + pub build_hash: Option, + /// IETF BCP 47 locale tag (e.g. `"en-US"`). + pub locale: Option, + pub client: ClientPlatform, +} +``` + +- [ ] **Step 4: Run tests, verify they pass** + +Run: `cargo test -p willow-common feedback_category_round_trips client_platform_round_trips feedback_diagnostics_round_trips` +Expected: 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add crates/common/src/worker_types.rs +git commit -m "feat(common): add FeedbackCategory, ClientPlatform, FeedbackDiagnostics wire types" +``` + + From 779831569c325ee6e7ef17b5292095c8d3813365 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 05:40:58 +0000 Subject: [PATCH 07/27] docs(plan): Phase 1 Task 1.2 (Feedback variants on worker enums) --- docs/plans/2026-04-27-feedback-system.md | 198 +++++++++++++++++++++++ 1 file changed, 198 insertions(+) diff --git a/docs/plans/2026-04-27-feedback-system.md b/docs/plans/2026-04-27-feedback-system.md index 242b6f59..41cd837b 100644 --- a/docs/plans/2026-04-27-feedback-system.md +++ b/docs/plans/2026-04-27-feedback-system.md @@ -216,4 +216,202 @@ git add crates/common/src/worker_types.rs git commit -m "feat(common): add FeedbackCategory, ClientPlatform, FeedbackDiagnostics wire types" ``` +### Task 1.2: Add `FeedbackErrReason` and feedback variants on `WorkerRequest` / `WorkerResponse` / `WorkerRoleInfo` + +**Files:** +- Modify: `crates/common/src/worker_types.rs` + +This task extends three existing enums and adds `#[non_exhaustive]` to each as a forward-compat consumer-side guard. It also extends `WorkerRoleInfo::role_name()` so feedback identifies itself in heartbeats. + +- [ ] **Step 1: Write the failing tests** + +Append to the `#[cfg(test)] mod tests` block: + +```rust +#[test] +fn feedback_err_reason_variants_round_trip() { + for r in [ + FeedbackErrReason::RateLimited { retry_after_ms: 12_345 }, + FeedbackErrReason::InvalidInput { + field: "title".to_string(), + message: "too long".to_string(), + }, + FeedbackErrReason::GithubFailure { + status: 422, + message: Some("Validation Failed".to_string()), + }, + FeedbackErrReason::GithubFailure { status: 0, message: None }, + FeedbackErrReason::Unconfigured, + ] { + let bytes = bincode::serialize(&r).unwrap(); + let decoded: FeedbackErrReason = bincode::deserialize(&bytes).unwrap(); + assert_eq!(r, decoded); + } +} + +#[test] +fn worker_request_feedback_round_trip() { + let id = Identity::generate(); + let req = WorkerRequest::Feedback { + dedup_id: [7u8; 16], + title: "It crashes".to_string(), + category: FeedbackCategory::Bug, + body: "Steps:\n1. open the app\n2. it crashes".to_string(), + diagnostics: Some(FeedbackDiagnostics { + app_version: "0.1.0".to_string(), + build_hash: Some("abc1234".to_string()), + locale: Some("en-US".to_string()), + client: ClientPlatform::Web { + ua_family: "firefox/138".to_string(), + }, + }), + }; + let msg = WorkerWireMessage::Request { + request_id: "rid-1".to_string(), + target_peer: id.endpoint_id(), + payload: req.clone(), + }; + let decoded = worker_wire_round_trip(msg, &id); + match decoded { + WorkerWireMessage::Request { payload, .. } => assert_eq!(payload, req), + _ => panic!("expected Request"), + } +} + +#[test] +fn worker_response_feedback_round_trip() { + let id = Identity::generate(); + for resp in [ + WorkerResponse::FeedbackOk { + issue_url: "https://github.com/x/y/issues/42".to_string(), + }, + WorkerResponse::FeedbackErr { + reason: FeedbackErrReason::RateLimited { retry_after_ms: 60_000 }, + }, + ] { + let msg = WorkerWireMessage::Response { + request_id: "rid-1".to_string(), + target_peer: id.endpoint_id(), + payload: Box::new(resp.clone()), + }; + let decoded = worker_wire_round_trip(msg, &id); + match decoded { + WorkerWireMessage::Response { payload, .. } => assert_eq!(*payload, resp), + _ => panic!("expected Response"), + } + } +} + +#[test] +fn worker_role_info_feedback_round_trip_and_name() { + let info = WorkerRoleInfo::Feedback { + reports_accepted: 17, + reports_rejected: 4, + currently_rate_limited: 2, + global_rate_limited: false, + }; + let bytes = bincode::serialize(&info).unwrap(); + let decoded: WorkerRoleInfo = bincode::deserialize(&bytes).unwrap(); + assert_eq!(info, decoded); + assert_eq!(info.role_name(), "feedback"); +} +``` + +- [ ] **Step 2: Run tests, verify compile failures** + +Run: `cargo test -p willow-common feedback_err_reason_variants_round_trip worker_request_feedback_round_trip worker_response_feedback_round_trip worker_role_info_feedback_round_trip_and_name` +Expected: COMPILE FAIL — missing variants on `WorkerRequest`, `WorkerResponse`, `WorkerRoleInfo`, and missing `FeedbackErrReason` type. + +- [ ] **Step 3: Add the new variants and the error enum** + +Edit `crates/common/src/worker_types.rs`: + +1. Add `#[non_exhaustive]` to `WorkerRoleInfo`, `WorkerRequest`, and `WorkerResponse` (the existing enum declarations). + +2. Add a new `Feedback` variant to `WorkerRoleInfo` (alongside `Replay` and `Storage`): + + ```rust + Feedback { + reports_accepted: u64, + reports_rejected: u64, + /// Gauge: peers currently throttled by the per-peer bucket. + currently_rate_limited: u32, + /// Gauge: true if the worker is hot-tripped on the global cap. + global_rate_limited: bool, + }, + ``` + +3. Extend `WorkerRoleInfo::role_name()` (currently around line 40 of the file) with a new arm: + + ```rust + WorkerRoleInfo::Feedback { .. } => "feedback", + ``` + +4. Add a new `Feedback` variant to `WorkerRequest`: + + ```rust + Feedback { + /// 16-byte client-generated dedup key. Worker maintains an LRU + /// cache of (signer, dedup_id) → issue_url so retries return + /// the original URL. + dedup_id: [u8; 16], + /// 1..=200 chars (worker-validated). + title: String, + category: FeedbackCategory, + /// 1..=8000 chars (worker-validated). Worker wraps this + /// verbatim in a fenced markdown code block on GitHub. + body: String, + diagnostics: Option, + }, + ``` + +5. Add two new variants to `WorkerResponse`: + + ```rust + FeedbackOk { issue_url: String }, + FeedbackErr { reason: FeedbackErrReason }, + ``` + +6. Add the new error enum *above* the `#[cfg(test)] mod tests` block: + + ```rust + /// Reason a feedback request was rejected. Units are MILLISECONDS to + /// align with the broader `WireRejectReason` design + /// ([`docs/specs/2026-04-24-error-prefixes.md`]); consolidating the + /// two enums is a follow-up. + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] + #[non_exhaustive] + pub enum FeedbackErrReason { + RateLimited { retry_after_ms: u64 }, + /// `field` <= 64 chars; `message` <= 200 chars (worker-enforced + /// before constructing the reply, client-enforced on receipt). + InvalidInput { field: String, message: String }, + GithubFailure { + status: u16, + /// GitHub's `message` field, truncated to 200 chars. + message: Option, + }, + /// Worker has no PAT configured, or PAT was revoked (401). + Unconfigured, + } + ``` + +- [ ] **Step 4: Run tests, verify all pass** + +Run: `cargo test -p willow-common` +Expected: all tests pass — both the four new feedback tests and every pre-existing test (the `#[non_exhaustive]` attributes don't change runtime behavior, only consumer-side compile guards). + +- [ ] **Step 5: Verify nothing breaks downstream** + +Run: `cargo check --workspace --all-targets 2>&1 | tail -40` +Expected: pass. The `#[non_exhaustive]` markers will only break callers that match exhaustively — workspace-internal callers all match exhaustively, so any failures here mean we forgot a `_` arm somewhere. Add `_ => unreachable!()` (or a real arm) at every match site that breaks. Likely candidates are display/log code paths in `crates/worker`, `crates/replay`, `crates/storage`. + +- [ ] **Step 6: Commit** + +```bash +git add crates/common/src/worker_types.rs +# Add any other files modified in step 5 above +git commit -m "feat(common): add Feedback variants to worker wire types" +``` + From 149fd45143e03dc9694518afb25f01b145764ffc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 05:41:48 +0000 Subject: [PATCH 08/27] docs(plan): Phase 1 Task 1.3 (async trait + signer plumbing) --- docs/plans/2026-04-27-feedback-system.md | 225 +++++++++++++++++++++++ 1 file changed, 225 insertions(+) diff --git a/docs/plans/2026-04-27-feedback-system.md b/docs/plans/2026-04-27-feedback-system.md index 41cd837b..abd5fdb7 100644 --- a/docs/plans/2026-04-27-feedback-system.md +++ b/docs/plans/2026-04-27-feedback-system.md @@ -414,4 +414,229 @@ git add crates/common/src/worker_types.rs git commit -m "feat(common): add Feedback variants to worker wire types" ``` +### Task 1.3: Make `WorkerRole::handle_request` async + add `signer` parameter + +**Files:** +- Modify: `crates/common/src/worker_types.rs` (the `WorkerRole` trait) +- Modify: `crates/worker/Cargo.toml` (add `async-trait` dep) +- Modify: `crates/worker/src/actors/mod.rs` (`WorkerRequestMsg` carries signer) +- Modify: `crates/worker/src/actors/state.rs` (handler awaits role, threads signer) +- Modify: `crates/worker/src/actors/network.rs` (pass requester signer into `WorkerRequestMsg`) +- Modify: `crates/worker/src/actors/sync.rs` (test role + internal sync requests pass local peer as signer) +- Modify: `crates/worker/src/actors/heartbeat.rs` (test role becomes async) +- Modify: `crates/replay/src/role.rs` (impl becomes `async fn handle_request(_signer, ...)`) +- Modify: `crates/storage/src/role.rs` (same) + +This is the load-bearing change. Approach: extend the message struct first (so the field exists), then update the trait, then fix the four impl sites in lockstep. Compile-driven — `cargo check` tells us when we've covered every site. + +- [ ] **Step 1: Inspect every existing `impl WorkerRole` site so the change is covered** + +Run: `grep -rn "impl WorkerRole\|fn handle_request" crates/` +Expected output (the current impls — confirm these match before continuing): + +- `crates/replay/src/role.rs:264` — `fn handle_request(&mut self, req: WorkerRequest) -> WorkerResponse` +- `crates/storage/src/role.rs:62` — same +- `crates/worker/src/actors/state.rs:127` — TestRole (in tests) +- `crates/worker/src/actors/sync.rs:108` — TestSyncRole (in tests) +- `crates/worker/src/actors/heartbeat.rs:124` — TestRole (in tests) + +If line numbers drift, follow the names — there are exactly five impls and one trait declaration to update. + +- [ ] **Step 2: Add `async-trait` to `crates/worker/Cargo.toml`** + +Add under `[dependencies]`: + +```toml +async-trait = "0.1" +``` + +Run: `cargo check -p willow-worker` +Expected: pass (just pulls the dep). + +- [ ] **Step 3: Add `EndpointId` to `WorkerRequestMsg`** + +In `crates/worker/src/actors/mod.rs` (around line 26), update the message: + +```rust +// Before: +pub struct WorkerRequestMsg(pub WorkerRequest); + +// After: +pub struct WorkerRequestMsg { + pub req: willow_common::WorkerRequest, + pub signer: willow_identity::EndpointId, +} +``` + +- [ ] **Step 4: Update the trait declaration in `willow-common`** + +In `crates/common/src/worker_types.rs` (around line 131), change: + +```rust +// Before: +pub trait WorkerRole: Send + 'static { + fn role_info(&self) -> WorkerRoleInfo; + fn on_event(&mut self, event: &Event); + fn handle_request(&mut self, req: WorkerRequest) -> WorkerResponse; + fn heads_summaries(&self) -> Vec<(String, HeadsSummary)> { + vec![] + } +} + +// After: +#[async_trait::async_trait] +pub trait WorkerRole: Send + 'static { + fn role_info(&self) -> WorkerRoleInfo; + fn on_event(&mut self, event: &Event); + /// Handle an inbound request from a client. `signer` is the + /// verified Ed25519 signer of the inbound `WireMessage`; roles + /// that don't need it (replay, storage) ignore the parameter. + async fn handle_request( + &mut self, + signer: willow_identity::EndpointId, + req: WorkerRequest, + ) -> WorkerResponse; + fn heads_summaries(&self) -> Vec<(String, HeadsSummary)> { + vec![] + } +} +``` + +Add `async-trait` to `crates/common/Cargo.toml` `[dependencies]`: + +```toml +async-trait = "0.1" +``` + +- [ ] **Step 5: Run cargo check to discover every impl site that needs updating** + +Run: `cargo check --workspace --all-targets 2>&1 | grep -E 'error\[|fn handle_request' | head -40` +Expected: errors at five impl sites — replay, storage, and the three test roles. Each will say "method `handle_request` has an incompatible type for trait" or similar. + +- [ ] **Step 6: Update `ReplayRole` impl in `crates/replay/src/role.rs`** + +Find the `impl WorkerRole for ReplayRole` block (currently around line 223 with `handle_request` at ~264). Replace the trait impl: + +```rust +#[async_trait::async_trait] +impl WorkerRole for ReplayRole { + // ... (role_info, on_event unchanged) ... + + async fn handle_request( + &mut self, + _signer: willow_identity::EndpointId, + req: WorkerRequest, + ) -> WorkerResponse { + // existing body unchanged + } + + // ... (heads_summaries unchanged) ... +} +``` + +Add `async-trait = "0.1"` to `crates/replay/Cargo.toml` `[dependencies]`. + +Run: `cargo check -p willow-replay` +Expected: pass. + +- [ ] **Step 7: Update `StorageRole` impl in `crates/storage/src/role.rs`** + +Same shape as Step 6 — wrap the impl in `#[async_trait::async_trait]`, prepend `async`, accept `_signer: EndpointId`. Add `async-trait = "0.1"` to `crates/storage/Cargo.toml`. + +Run: `cargo check -p willow-storage` +Expected: pass. + +- [ ] **Step 8: Update the three test-role impls in `crates/worker/src/actors/`** + +For each of `state.rs:113`, `heartbeat.rs:124`, `sync.rs:108`, wrap the impl in `#[async_trait::async_trait]`, change the signature to: + +```rust +async fn handle_request( + &mut self, + _signer: willow_identity::EndpointId, + req: WorkerRequest, +) -> WorkerResponse { + // existing body +} +``` + +Run: `cargo check -p willow-worker --all-targets` +Expected: pass. + +- [ ] **Step 9: Update the state actor's handler to `.await` the role and pass the signer** + +In `crates/worker/src/actors/state.rs` (around line 52), the existing handler is: + +```rust +impl Handler for StateActor { + fn handle( + &mut self, + msg: WorkerRequestMsg, + _ctx: &mut Context, + ) -> impl std::future::Future + Send { + let response = self.role.handle_request(msg.0); + async move { response } + } +} +``` + +Replace with: + +```rust +impl Handler for StateActor { + async fn handle( + &mut self, + msg: WorkerRequestMsg, + _ctx: &mut Context, + ) -> crate::types::WorkerResponse { + self.role.handle_request(msg.signer, msg.req).await + } +} +``` + +- [ ] **Step 10: Update internal `ask` callers in `state.rs` and `sync.rs` that construct `WorkerRequestMsg`** + +Internal sync-actor code paths (like `crates/worker/src/actors/state.rs:186`, `:199`, `:244`) currently call `addr.ask(WorkerRequestMsg(WorkerRequest::Sync { ... }))`. These are *internal* synthetic requests, not from the network; they should pass the worker's own peer ID as the signer. + +Find each `WorkerRequestMsg(WorkerRequest::...)` construction in `crates/worker/src/`. There's a pre-existing `local_peer_id` in scope at `network.rs`; `state.rs` and `sync.rs` need to obtain it from the actor's stored identity. If the actor doesn't currently hold the peer ID, plumb it in via the actor's constructor. Update each call site to: + +```rust +addr.ask(WorkerRequestMsg { + req: WorkerRequest::Sync { /* ... */ }, + signer: self.local_peer_id, // or whatever the actor field is named +}).await +``` + +Run: `cargo check -p willow-worker --all-targets` +Expected: pass. + +- [ ] **Step 11: Update `network.rs` to pass the verified requester through** + +In `crates/worker/src/actors/network.rs` (around line 138), the existing call is: + +```rust +state_addr.ask(WorkerRequestMsg(payload)).await +``` + +`requester` (the verified gossip signer) is already in scope at line 133. Replace with: + +```rust +state_addr.ask(WorkerRequestMsg { req: payload, signer: requester }).await +``` + +Run: `cargo check -p willow-worker --all-targets` +Expected: pass. + +- [ ] **Step 12: Run all worker-side tests** + +Run: `cargo test -p willow-common -p willow-worker -p willow-replay -p willow-storage` +Expected: all tests pass. The trait change is observationally invisible to existing roles — they ignore the new parameter and don't await anything inside `handle_request`. + +- [ ] **Step 13: Commit** + +```bash +git add crates/ +git commit -m "refactor(worker): make WorkerRole::handle_request async + accept signer" +``` + From 7c3be80974a24bd9401b93b548aa7f874e03fd49 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 05:42:18 +0000 Subject: [PATCH 09/27] docs(plan): Phase 2 Task 2.1 (crate scaffold) --- docs/plans/2026-04-27-feedback-system.md | 140 +++++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/docs/plans/2026-04-27-feedback-system.md b/docs/plans/2026-04-27-feedback-system.md index abd5fdb7..62154b94 100644 --- a/docs/plans/2026-04-27-feedback-system.md +++ b/docs/plans/2026-04-27-feedback-system.md @@ -639,4 +639,144 @@ git add crates/ git commit -m "refactor(worker): make WorkerRole::handle_request async + accept signer" ``` +--- + +## Phase 2: `willow-feedback` crate + +**Why now:** the trait is async and signer-aware; all the new wire types exist. We can build the worker on top. + +**Approach:** TDD against the `FeedbackRole` directly. We isolate GitHub via a `GithubClient` trait so role tests don't talk to the network. The role is the integration point; the supporting modules (`sanitize`, `handle`, `ratelimit`, `salt`, `throttle`) each have their own focused unit tests. + +### Task 2.1: Scaffold the crate + +**Files:** +- Create: `crates/feedback/Cargo.toml` +- Create: `crates/feedback/build.rs` +- Create: `crates/feedback/src/main.rs` (placeholder so `cargo check` passes) +- Create: `crates/feedback/src/lib.rs` (empty placeholder; modules added in later tasks) +- Modify: `Cargo.toml` (root) — add `crates/feedback` to `[workspace] members` + +- [ ] **Step 1: Add the workspace member** + +Edit the root `Cargo.toml` `[workspace] members` array. Insert `"crates/feedback"` alphabetically. + +- [ ] **Step 2: Create `crates/feedback/Cargo.toml`** + +```toml +[package] +name = "willow-feedback" +version = "0.1.0" +edition.workspace = true + +[[bin]] +name = "willow-feedback" +path = "src/main.rs" + +[dependencies] +willow-common = { path = "../common" } +willow-identity = { path = "../identity" } +willow-network = { path = "../network" } +willow-state = { path = "../state" } +willow-worker = { path = "../worker" } + +anyhow = { workspace = true } +async-trait = "0.1" +blake3 = "1" +bytes = { workspace = true } +clap = { version = "4", features = ["derive"] } +filetime = "0.2" +rand = { version = "0.8", features = ["std", "std_rng"] } +regex = "1" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +secrecy = "0.10" +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["full"] } +tracing = { workspace = true } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +url = "2" + +[dev-dependencies] +tracing-test = "0.2" +``` + +- [ ] **Step 3: Create `crates/feedback/build.rs` to inject the build SHA** + +```rust +//! Inject `WILLOW_BUILD_SHA` via `option_env!` so diagnostics can +//! surface the short git SHA. Best-effort: empty in dev builds. + +use std::process::Command; + +fn main() { + println!("cargo:rerun-if-env-changed=WILLOW_BUILD_SHA"); + if std::env::var_os("WILLOW_BUILD_SHA").is_some() { + return; // caller already set it + } + if let Ok(out) = Command::new("git") + .args(["rev-parse", "--short", "HEAD"]) + .output() + { + if out.status.success() { + let sha = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !sha.is_empty() { + println!("cargo:rustc-env=WILLOW_BUILD_SHA={sha}"); + } + } + } +} +``` + +- [ ] **Step 4: Create a placeholder `crates/feedback/src/lib.rs`** + +```rust +//! Willow feedback worker library. Modules added in subsequent +//! plan tasks. + +pub mod role; +pub mod github; +pub mod handle; +pub mod ratelimit; +pub mod sanitize; +pub mod salt; +pub mod throttle; +pub mod wordlist; +``` + +(Each `pub mod` line will fail compilation until the corresponding file exists. Add them as we go — Step 5 only stubs the binary so `cargo check` passes for now. We delete this `lib.rs` placeholder content and replace it as each module is added.) + +For Step 5's check to pass right now, *temporarily* leave `lib.rs` empty: + +```rust +//! Willow feedback worker library. Modules added in subsequent +//! plan tasks. +``` + +- [ ] **Step 5: Create a placeholder `crates/feedback/src/main.rs`** + +```rust +//! Willow Feedback Node — stub. +//! +//! Filled in by Task 2.10. This stub exists so the crate compiles +//! while earlier modules are being built. + +fn main() { + eprintln!("willow-feedback: not yet implemented"); + std::process::exit(1); +} +``` + +- [ ] **Step 6: Verify the crate compiles** + +Run: `cargo check -p willow-feedback` +Expected: pass. + +- [ ] **Step 7: Commit** + +```bash +git add Cargo.toml crates/feedback/ +git commit -m "feat(feedback): scaffold willow-feedback crate" +``` + From 3c1ca0b4045676301d64c3461d9bcf8bd2098658 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 05:43:09 +0000 Subject: [PATCH 10/27] docs(plan): Phase 2 Task 2.2 (sanitize.rs TDD) --- docs/plans/2026-04-27-feedback-system.md | 262 +++++++++++++++++++++++ 1 file changed, 262 insertions(+) diff --git a/docs/plans/2026-04-27-feedback-system.md b/docs/plans/2026-04-27-feedback-system.md index 62154b94..d5f36344 100644 --- a/docs/plans/2026-04-27-feedback-system.md +++ b/docs/plans/2026-04-27-feedback-system.md @@ -779,4 +779,266 @@ git add Cargo.toml crates/feedback/ git commit -m "feat(feedback): scaffold willow-feedback crate" ``` +### Task 2.2: Body + title sanitization (`sanitize.rs`) + +**Files:** +- Create: `crates/feedback/src/sanitize.rs` + +This is the security boundary. Tests come first, drive the implementation. + +- [ ] **Step 1: Write the failing tests** + +Create `crates/feedback/src/sanitize.rs`: + +```rust +//! User-supplied content sanitization for feedback issues. +//! +//! - `wrap_body_fenced` wraps the user body in a backtick code block +//! long enough that no closing fence inside the body can escape. +//! - `sanitize_title` strips control / bidi codepoints and escapes +//! leading brackets so the assembled title can't impersonate the +//! metadata block. + +#[cfg(test)] +mod tests { + use super::*; + + /// Helper: assert the wrapped body is well-formed and the inner + /// content survives byte-for-byte (modulo CRLF normalization). + fn assert_wrap_round_trips(input: &str) { + let wrapped = wrap_body_fenced(input); + let normalized = input.replace("\r\n", "\n"); + assert!(wrapped.starts_with('`'), "must open with backticks"); + assert!( + wrapped.contains(&normalized), + "wrapped body must contain the normalized input verbatim" + ); + } + + #[test] + fn wraps_plain_body_with_min_three_backticks() { + let out = wrap_body_fenced("hello world"); + assert!(out.starts_with("```text\n")); + assert!(out.ends_with("\n```")); + } + + #[test] + fn escapes_body_containing_three_backticks() { + let body = "code: ```\nrust\n```\nend"; + let out = wrap_body_fenced(body); + // Must use at least 4 backticks since body has runs of 3. + assert!(out.starts_with("````text\n")); + assert!(out.ends_with("\n````")); + assert!(out.contains(body)); + } + + #[test] + fn handles_indented_closing_fence() { + // Up-to-3-space indent counts as a valid close per CommonMark. + let body = "stuff\n ```\nmore"; + let out = wrap_body_fenced(body); + assert!(out.starts_with("````text\n")); + } + + #[test] + fn ignores_four_space_indent() { + // 4+ spaces before backticks is a code block, not a fence. + let body = "stuff\n ```\nmore"; + let out = wrap_body_fenced(body); + assert!(out.starts_with("```text\n"), "no escalation needed"); + } + + #[test] + fn handles_crlf_line_endings() { + let body = "line1\r\n```\r\nline3"; + let out = wrap_body_fenced(body); + assert!(out.starts_with("````text\n")); + // Wrapped output uses LF only. + assert!(!out.contains("\r\n")); + } + + #[test] + fn ignores_tilde_fences() { + let body = "~~~\nhi\n~~~"; + let out = wrap_body_fenced(body); + assert!(out.starts_with("```text\n"), "tildes don't close backticks"); + } + + #[test] + fn handles_info_string_after_fence() { + // `\`\`\`text` on its own line is a CLOSE if it's just backticks + // and whitespace; with `text` after, it's an open. Sanitizer + // must still escalate because the regex `^[ ]{0,3}\`{N,}[ \t]*$` + // only matches *closing* fences. + let body = "stuff\n```\nmore"; // bare close + let out = wrap_body_fenced(body); + assert!(out.starts_with("````text\n")); + + let body2 = "stuff\n```rust\nmore"; // not a close + let out2 = wrap_body_fenced(body2); + assert!(out2.starts_with("```text\n")); + } + + #[test] + fn html_entity_backticks_dont_escape() { + // HTML entities are rendered as text inside fenced blocks, so + // they don't escape — sanitizer doesn't need to do anything. + let body = "```\n`code`\n```"; + let out = wrap_body_fenced(body); + assert!(out.starts_with("```text\n")); + assert!(out.contains(body)); + } + + #[test] + fn five_backticks_in_body_escalates_to_six() { + let body = "weird: `````".to_string(); + let out = wrap_body_fenced(&body); + assert!(out.starts_with("``````text\n"), "got: {}", out); + } + + #[test] + fn wrap_round_trips_assorted_inputs() { + for s in [ + "", + "hello", + "@everyone please look", + "![pixel](https://attacker/?ip=)", + "", + "[link](javascript:alert(1))", + "#1 issue cross-ref", + ] { + assert_wrap_round_trips(s); + } + } + + #[test] + fn sanitize_title_strips_controls() { + let raw = "hello\u{0007}world\u{0001}"; + assert_eq!(sanitize_title(raw), "helloworld"); + } + + #[test] + fn sanitize_title_strips_bidi_overrides() { + let raw = "hello\u{202E}evil"; + assert_eq!(sanitize_title(raw), "helloevil"); + } + + #[test] + fn sanitize_title_collapses_internal_whitespace() { + let raw = "hello \tworld bar"; + assert_eq!(sanitize_title(raw), "hello world bar"); + } + + #[test] + fn sanitize_title_escapes_leading_brackets() { + assert_eq!(sanitize_title("[bug] crash"), r"\[bug\] crash"); + assert_eq!(sanitize_title("]nope"), r"\]nope"); + } +} +``` + +- [ ] **Step 2: Wire `sanitize` into `lib.rs`** + +Replace the placeholder content of `crates/feedback/src/lib.rs` with: + +```rust +//! Willow feedback worker library. + +pub mod sanitize; +``` + +- [ ] **Step 3: Run tests, verify failure** + +Run: `cargo test -p willow-feedback --lib sanitize::tests` +Expected: COMPILE FAIL — `cannot find function wrap_body_fenced` and `sanitize_title`. + +- [ ] **Step 4: Implement the sanitizers** + +Append to `crates/feedback/src/sanitize.rs` *above* the test module: + +```rust +use regex::Regex; +use std::sync::OnceLock; + +/// Match a CommonMark closing-fence line for backtick fences: +/// 0–3 leading spaces, three or more backticks, optional trailing +/// whitespace, end of line. +fn close_fence_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"^[ ]{0,3}(`{3,})[ \t]*$").unwrap()) +} + +/// Wrap `body` in a backtick fenced markdown block with the `text` +/// info-string. Fence length is the smallest N ≥ 3 such that no line +/// in the body is `^[ ]{0,3}` `` ` ``{N,}` `[ \t]*$` — guaranteeing +/// no body line can close our fence. +/// +/// CRLF line endings are normalized to LF before scanning and in the +/// output. +pub fn wrap_body_fenced(body: &str) -> String { + let body = body.replace("\r\n", "\n"); + let mut max_run: usize = 0; + for line in body.split('\n') { + if let Some(c) = close_fence_re().captures(line) { + let n = c.get(1).unwrap().as_str().len(); + if n > max_run { + max_run = n; + } + } + } + let fence_len = std::cmp::max(3, max_run + 1); + let fence = "`".repeat(fence_len); + format!("{fence}text\n{body}\n{fence}") +} + +/// Sanitize a feedback title. Strips ASCII control codepoints +/// (0x00–0x1F, 0x7F) and Unicode bidi/RTL override codepoints +/// (U+202A..=U+202E, U+2066..=U+2069). Collapses internal +/// runs of whitespace to single spaces. Escapes leading `[` / `]` +/// with a backslash so the assembled title can't impersonate the +/// metadata-block prefix. +pub fn sanitize_title(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + let mut last_was_ws = false; + for ch in raw.chars() { + let c = ch as u32; + let is_ascii_control = c <= 0x1F || c == 0x7F; + let is_bidi_override = matches!(c, 0x202A..=0x202E | 0x2066..=0x2069); + if is_ascii_control || is_bidi_override { + continue; + } + if ch.is_whitespace() { + if !last_was_ws && !out.is_empty() { + out.push(' '); + } + last_was_ws = true; + } else { + last_was_ws = false; + out.push(ch); + } + } + let trimmed = out.trim_end().to_string(); + // Escape leading [ or ] so a user title can't fake the worker prefix. + if trimmed.starts_with('[') { + format!(r"\[{}", &trimmed[1..]) + } else if trimmed.starts_with(']') { + format!(r"\]{}", &trimmed[1..]) + } else { + trimmed + } +} +``` + +- [ ] **Step 5: Run tests, verify all pass** + +Run: `cargo test -p willow-feedback --lib sanitize::tests` +Expected: all 14 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add crates/feedback/src/lib.rs crates/feedback/src/sanitize.rs +git commit -m "feat(feedback): add body + title sanitization" +``` + From 5ae61edac18166d6be63c08bd4bd2517ada07f9b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 05:43:51 +0000 Subject: [PATCH 11/27] docs(plan): Phase 2 Task 2.3 (BIP-39 wordlist + reporter handle) --- docs/plans/2026-04-27-feedback-system.md | 197 +++++++++++++++++++++++ 1 file changed, 197 insertions(+) diff --git a/docs/plans/2026-04-27-feedback-system.md b/docs/plans/2026-04-27-feedback-system.md index d5f36344..b5cf2de1 100644 --- a/docs/plans/2026-04-27-feedback-system.md +++ b/docs/plans/2026-04-27-feedback-system.md @@ -1041,4 +1041,201 @@ git add crates/feedback/src/lib.rs crates/feedback/src/sanitize.rs git commit -m "feat(feedback): add body + title sanitization" ``` +### Task 2.3: BIP-39 wordlist + salted reporter handle (`wordlist.rs`, `handle.rs`) + +**Files:** +- Create: `crates/feedback/src/wordlist.rs` +- Create: `crates/feedback/src/handle.rs` +- Modify: `crates/feedback/src/lib.rs` + +The reporter handle is `blake3(salt || peer_id)[..8]` rendered as 4 BIP-39 English words (44 bits) plus a 5-hex suffix (20 bits). + +- [ ] **Step 1: Vendor the BIP-39 English wordlist** + +The official BIP-39 English wordlist is 2048 words, ordered, all lowercase. The canonical source is https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt. Implementer downloads the raw file and writes it as a Rust array literal. Approach: + +1. Fetch the wordlist (one-time): + + ```bash + curl -sSf https://raw.githubusercontent.com/bitcoin/bips/master/bip-0039/english.txt > /tmp/bip39-english.txt + wc -l /tmp/bip39-english.txt + # expect: 2048 + ``` + +2. Generate the Rust file: + + ```bash + { + echo '//! Vendored BIP-39 English wordlist (2048 words). Source:' + echo '//! https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt' + echo + echo 'pub const WORDS: [&str; 2048] = [' + awk '{ printf " \"%s\",\n", $1 }' /tmp/bip39-english.txt + echo '];' + } > crates/feedback/src/wordlist.rs + ``` + +3. Spot-check a couple of canonical entries (the first word is `abandon`, the last is `zoo`). + +- [ ] **Step 2: Add a sanity test for the wordlist** + +Append to `crates/feedback/src/wordlist.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wordlist_has_2048_entries() { + assert_eq!(WORDS.len(), 2048); + } + + #[test] + fn first_and_last_words_are_canonical() { + assert_eq!(WORDS[0], "abandon"); + assert_eq!(WORDS[2047], "zoo"); + } + + #[test] + fn all_words_lowercase_and_nonempty() { + for w in WORDS { + assert!(!w.is_empty()); + assert_eq!(w, w.to_lowercase()); + } + } +} +``` + +- [ ] **Step 3: Wire into `lib.rs`** + +Edit `crates/feedback/src/lib.rs`: + +```rust +pub mod sanitize; +pub mod wordlist; +pub mod handle; +``` + +- [ ] **Step 4: Write the failing handle tests** + +Create `crates/feedback/src/handle.rs`: + +```rust +//! Salted reporter handle. Renders an opaque human-friendly string +//! from `(salt, peer_id_bytes)` so maintainers can correlate reports +//! from the same user without exposing the raw Ed25519 public key. + +use willow_identity::EndpointId; + +#[cfg(test)] +mod tests { + use super::*; + use willow_identity::Identity; + + #[test] + fn handle_is_deterministic_for_same_inputs() { + let id = Identity::generate().endpoint_id(); + let salt = [0xABu8; 32]; + let h1 = compute_handle(&salt, &id); + let h2 = compute_handle(&salt, &id); + assert_eq!(h1, h2); + } + + #[test] + fn handle_changes_when_salt_rotates() { + let id = Identity::generate().endpoint_id(); + let h1 = compute_handle(&[0u8; 32], &id); + let h2 = compute_handle(&[1u8; 32], &id); + assert_ne!(h1, h2); + } + + #[test] + fn handle_distinguishes_distinct_peers() { + let salt = [0u8; 32]; + let id1 = Identity::generate().endpoint_id(); + let id2 = Identity::generate().endpoint_id(); + assert_ne!(compute_handle(&salt, &id1), compute_handle(&salt, &id2)); + } + + #[test] + fn handle_format_is_four_words_dash_five_hex() { + let id = Identity::generate().endpoint_id(); + let h = compute_handle(&[0u8; 32], &id); + let parts: Vec<&str> = h.split('-').collect(); + assert_eq!(parts.len(), 5, "expected 4 words + 5-hex suffix, got {h}"); + for word in &parts[..4] { + assert!(crate::wordlist::WORDS.contains(word), "{word} not in wordlist"); + } + assert_eq!(parts[4].len(), 5); + assert!(parts[4].chars().all(|c| c.is_ascii_hexdigit())); + assert!(parts[4].chars().all(|c| !c.is_ascii_uppercase())); + } +} +``` + +- [ ] **Step 5: Run tests, expect compile failure** + +Run: `cargo test -p willow-feedback --lib handle::tests` +Expected: COMPILE FAIL — `cannot find function compute_handle`. + +- [ ] **Step 6: Implement `compute_handle`** + +Append to `crates/feedback/src/handle.rs` *above* the test module: + +```rust +use crate::wordlist::WORDS; + +/// Compute the salted-hash reporter handle. Layout: +/// - `blake3(salt || peer_id_bytes)[..8]` = 64 bits. +/// - First 44 bits → 4 BIP-39 English words (11 bits each). +/// - Last 20 bits → 5 lowercase hex chars. +/// Final form: `word-word-word-word-NNNNN`. +pub fn compute_handle(salt: &[u8; 32], peer_id: &EndpointId) -> String { + let mut hasher = blake3::Hasher::new(); + hasher.update(salt); + hasher.update(peer_id.as_bytes()); + let digest = hasher.finalize(); + let bytes = digest.as_bytes(); + // Pull the first 8 bytes into a u64 (big-endian). + let mut h64 = 0u64; + for &b in &bytes[..8] { + h64 = (h64 << 8) | (b as u64); + } + // Top 44 bits: four 11-bit words. + let mut words: [&'static str; 4] = ["", "", "", ""]; + for i in 0..4 { + let shift = 64 - (i + 1) * 11; + let idx = ((h64 >> shift) & 0x7FF) as usize; + words[i] = WORDS[idx]; + } + // Bottom 20 bits → 5 hex chars. + let suffix_bits = (h64 & 0xF_FFFF) as u32; // 20 bits + format!( + "{}-{}-{}-{}-{:05x}", + words[0], words[1], words[2], words[3], suffix_bits, + ) +} +``` + +This depends on `EndpointId::as_bytes()`. Verify that method exists: + +```bash +grep -n "fn as_bytes" crates/identity/src/lib.rs +``` + +If `as_bytes()` returns `&[u8; N]` for some `N`, the call works as-is. If it returns `Vec`, replace `peer_id.as_bytes()` with `&peer_id.as_bytes()[..]`. If neither method exists (unlikely), use `peer_id.to_string().as_bytes()` — bech32 form is also fine for this purpose. + +- [ ] **Step 7: Run tests, verify all pass** + +Run: `cargo test -p willow-feedback --lib handle::tests wordlist::tests` +Expected: 7 tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add crates/feedback/src/lib.rs crates/feedback/src/handle.rs crates/feedback/src/wordlist.rs +git commit -m "feat(feedback): salted-hash reporter handle (blake3 + BIP-39)" +``` + From 268f0877ef0ef925aa7cceb816f0116d92f67a6d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 05:44:41 +0000 Subject: [PATCH 12/27] docs(plan): Phase 2 Task 2.4 (token-bucket rate limiter) --- docs/plans/2026-04-27-feedback-system.md | 314 +++++++++++++++++++++++ 1 file changed, 314 insertions(+) diff --git a/docs/plans/2026-04-27-feedback-system.md b/docs/plans/2026-04-27-feedback-system.md index b5cf2de1..a683604d 100644 --- a/docs/plans/2026-04-27-feedback-system.md +++ b/docs/plans/2026-04-27-feedback-system.md @@ -1238,4 +1238,318 @@ git add crates/feedback/src/lib.rs crates/feedback/src/handle.rs crates/feedback git commit -m "feat(feedback): salted-hash reporter handle (blake3 + BIP-39)" ``` +### Task 2.4: Token-bucket rate limiter (`ratelimit.rs`) + +**Files:** +- Create: `crates/feedback/src/ratelimit.rs` +- Modify: `crates/feedback/src/lib.rs` + +A continuous-refill token bucket. We use `Instant` so tests can drive time deterministically via an injected clock — but for v1 simplicity we drive the clock through a small trait `Clock` with a real `SystemClock` and a test `MockClock`. + +- [ ] **Step 1: Write the failing tests** + +Create `crates/feedback/src/ratelimit.rs`: + +```rust +//! Continuous-refill token-bucket rate limiter. +//! +//! - Per-peer buckets keyed by `EndpointId`. +//! - One worker-wide global bucket. +//! - On rejection, returns the exact wait time to the next available +//! token in milliseconds. + +use std::collections::HashMap; +use std::time::Duration; + +use willow_identity::EndpointId; + +#[cfg(test)] +mod tests { + use super::*; + use willow_identity::Identity; + + fn peer() -> EndpointId { + Identity::generate().endpoint_id() + } + + #[test] + fn fresh_bucket_allows_burst_capacity() { + let mut clock = MockClock::new(); + let mut rl = RateLimiter::new(5, 50, &mut clock); + let p = peer(); + for _ in 0..5 { + assert!(rl.try_take(&p, &mut clock).is_ok()); + } + } + + #[test] + fn per_peer_limit_returns_retry_after() { + let mut clock = MockClock::new(); + let mut rl = RateLimiter::new(5, 50, &mut clock); + let p = peer(); + for _ in 0..5 { + rl.try_take(&p, &mut clock).unwrap(); + } + match rl.try_take(&p, &mut clock) { + Err(RateLimited::PerPeer { retry_after_ms }) => { + // Refill rate is 5/3600s ≈ 1 per 720s. + let expected_ms = 3600 * 1000 / 5; + let lo = expected_ms as i64 - 50; + let hi = expected_ms as i64 + 50; + assert!( + (retry_after_ms as i64) >= lo && (retry_after_ms as i64) <= hi, + "got {retry_after_ms}, expected near {expected_ms}" + ); + } + other => panic!("expected PerPeer, got {other:?}"), + } + } + + #[test] + fn distinct_peers_have_independent_buckets() { + let mut clock = MockClock::new(); + let mut rl = RateLimiter::new(2, 50, &mut clock); + let a = peer(); + let b = peer(); + rl.try_take(&a, &mut clock).unwrap(); + rl.try_take(&a, &mut clock).unwrap(); + assert!(matches!(rl.try_take(&a, &mut clock), Err(RateLimited::PerPeer { .. }))); + // b is unaffected. + rl.try_take(&b, &mut clock).unwrap(); + rl.try_take(&b, &mut clock).unwrap(); + } + + #[test] + fn global_limit_trips_across_distinct_peers() { + let mut clock = MockClock::new(); + // Per-peer 100 (won't trip), global 3. + let mut rl = RateLimiter::new(100, 3, &mut clock); + for _ in 0..3 { + let p = peer(); + rl.try_take(&p, &mut clock).unwrap(); + } + let p4 = peer(); + match rl.try_take(&p4, &mut clock) { + Err(RateLimited::Global { retry_after_ms }) => { + let expected_ms = 3600 * 1000 / 3; + assert!(retry_after_ms >= (expected_ms as u64).saturating_sub(50)); + } + other => panic!("expected Global, got {other:?}"), + } + } + + #[test] + fn refill_replenishes_a_token_after_the_advertised_wait() { + let mut clock = MockClock::new(); + let mut rl = RateLimiter::new(2, 50, &mut clock); + let p = peer(); + rl.try_take(&p, &mut clock).unwrap(); + rl.try_take(&p, &mut clock).unwrap(); + let err = rl.try_take(&p, &mut clock).unwrap_err(); + let wait_ms = match err { + RateLimited::PerPeer { retry_after_ms } => retry_after_ms, + _ => panic!("expected PerPeer"), + }; + clock.advance(Duration::from_millis(wait_ms)); + rl.try_take(&p, &mut clock).unwrap(); + } + + #[test] + fn currently_rate_limited_count_reflects_throttled_peers() { + let mut clock = MockClock::new(); + let mut rl = RateLimiter::new(1, 50, &mut clock); + let a = peer(); + let b = peer(); + rl.try_take(&a, &mut clock).unwrap(); + let _ = rl.try_take(&a, &mut clock); // a is now throttled + rl.try_take(&b, &mut clock).unwrap(); + // a is throttled (saturated bucket); b is not. + assert_eq!(rl.currently_rate_limited(&clock), 1); + } +} +``` + +- [ ] **Step 2: Run tests, expect compile failure** + +Run: `cargo test -p willow-feedback --lib ratelimit::tests` +Expected: COMPILE FAIL — types don't exist yet. + +- [ ] **Step 3: Implement the limiter** + +Append to `crates/feedback/src/ratelimit.rs` *above* the test module: + +```rust +/// Abstract clock so tests can drive time without sleeping. +pub trait Clock { + fn now(&self) -> std::time::Instant; +} + +#[derive(Default)] +pub struct SystemClock; +impl Clock for SystemClock { + fn now(&self) -> std::time::Instant { + std::time::Instant::now() + } +} + +#[cfg(test)] +pub struct MockClock { + base: std::time::Instant, + offset: Duration, +} +#[cfg(test)] +impl MockClock { + pub fn new() -> Self { + Self { + base: std::time::Instant::now(), + offset: Duration::ZERO, + } + } + pub fn advance(&mut self, by: Duration) { + self.offset += by; + } +} +#[cfg(test)] +impl Clock for MockClock { + fn now(&self) -> std::time::Instant { + self.base + self.offset + } +} + +#[derive(Debug)] +pub enum RateLimited { + PerPeer { retry_after_ms: u64 }, + Global { retry_after_ms: u64 }, +} + +#[derive(Clone, Copy)] +struct Bucket { + /// Tokens available, fractional (refills smoothly between integers). + tokens: f64, + /// Last time we updated `tokens`. + last: std::time::Instant, +} + +impl Bucket { + fn fresh(capacity: u32, now: std::time::Instant) -> Self { + Self { + tokens: capacity as f64, + last: now, + } + } + fn refill(&mut self, capacity: u32, refill_per_sec: f64, now: std::time::Instant) { + let elapsed = now.saturating_duration_since(self.last).as_secs_f64(); + self.tokens = (self.tokens + elapsed * refill_per_sec).min(capacity as f64); + self.last = now; + } + fn try_take(&mut self, capacity: u32, refill_per_sec: f64, now: std::time::Instant) -> Result<(), u64> { + self.refill(capacity, refill_per_sec, now); + if self.tokens >= 1.0 { + self.tokens -= 1.0; + Ok(()) + } else { + // Time to next full token. + let need = 1.0 - self.tokens; + let secs = need / refill_per_sec; + Err((secs * 1000.0).ceil() as u64) + } + } + fn is_throttled(&self, capacity: u32, refill_per_sec: f64, now: std::time::Instant) -> bool { + let elapsed = now.saturating_duration_since(self.last).as_secs_f64(); + let projected = (self.tokens + elapsed * refill_per_sec).min(capacity as f64); + projected < 1.0 + } +} + +pub struct RateLimiter { + per_peer_capacity: u32, + per_peer_refill_per_sec: f64, + global_capacity: u32, + global_refill_per_sec: f64, + per_peer: HashMap, + global: Bucket, +} + +impl RateLimiter { + pub fn new(per_peer_per_hour: u32, global_per_hour: u32, clock: &mut impl Clock) -> Self { + let now = clock.now(); + Self { + per_peer_capacity: per_peer_per_hour, + per_peer_refill_per_sec: per_peer_per_hour as f64 / 3600.0, + global_capacity: global_per_hour, + global_refill_per_sec: global_per_hour as f64 / 3600.0, + per_peer: HashMap::new(), + global: Bucket::fresh(global_per_hour, now), + } + } + + pub fn try_take( + &mut self, + peer: &EndpointId, + clock: &mut impl Clock, + ) -> Result<(), RateLimited> { + let now = clock.now(); + let bucket = self + .per_peer + .entry(*peer) + .or_insert_with(|| Bucket::fresh(self.per_peer_capacity, now)); + if let Err(retry_after_ms) = + bucket.try_take(self.per_peer_capacity, self.per_peer_refill_per_sec, now) + { + return Err(RateLimited::PerPeer { retry_after_ms }); + } + if let Err(retry_after_ms) = + self.global + .try_take(self.global_capacity, self.global_refill_per_sec, now) + { + // Refund the per-peer token we just took: the request didn't + // really happen. + let bucket = self.per_peer.get_mut(peer).unwrap(); + bucket.tokens += 1.0; + return Err(RateLimited::Global { retry_after_ms }); + } + Ok(()) + } + + /// Number of peers whose per-peer bucket is currently throttled + /// (would deny a `try_take` right now without waiting). + pub fn currently_rate_limited(&self, clock: &impl Clock) -> u32 { + let now = clock.now(); + self.per_peer + .values() + .filter(|b| { + b.is_throttled(self.per_peer_capacity, self.per_peer_refill_per_sec, now) + }) + .count() as u32 + } + + pub fn global_is_throttled(&self, clock: &impl Clock) -> bool { + let now = clock.now(); + self.global + .is_throttled(self.global_capacity, self.global_refill_per_sec, now) + } +} +``` + +- [ ] **Step 4: Wire into `lib.rs`** + +```rust +pub mod sanitize; +pub mod wordlist; +pub mod handle; +pub mod ratelimit; +``` + +- [ ] **Step 5: Run tests, verify all pass** + +Run: `cargo test -p willow-feedback --lib ratelimit::tests` +Expected: 6 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add crates/feedback/src/lib.rs crates/feedback/src/ratelimit.rs +git commit -m "feat(feedback): continuous-refill token-bucket rate limiter" +``` + From 2b0bb851f77023a7ef873633946c2ef5fcd1d6f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 05:45:33 +0000 Subject: [PATCH 13/27] docs(plan): Phase 2 Tasks 2.5-2.6 (salt + startup throttle) --- docs/plans/2026-04-27-feedback-system.md | 319 +++++++++++++++++++++++ 1 file changed, 319 insertions(+) diff --git a/docs/plans/2026-04-27-feedback-system.md b/docs/plans/2026-04-27-feedback-system.md index a683604d..3bdfbaa3 100644 --- a/docs/plans/2026-04-27-feedback-system.md +++ b/docs/plans/2026-04-27-feedback-system.md @@ -1552,4 +1552,323 @@ git add crates/feedback/src/lib.rs crates/feedback/src/ratelimit.rs git commit -m "feat(feedback): continuous-refill token-bucket rate limiter" ``` +### Task 2.5: Salt file load-or-generate (`salt.rs`) + +**Files:** +- Create: `crates/feedback/src/salt.rs` +- Modify: `crates/feedback/src/lib.rs` + +- [ ] **Step 1: Write the failing tests** + +Create `crates/feedback/src/salt.rs`: + +```rust +//! 32-byte salt file used by the reporter-handle hash. Loaded at +//! startup; regenerated on demand via the `--generate-salt` CLI +//! flag (which writes a fresh salt and exits if the file is missing). + +use std::path::Path; + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn load_salt_returns_32_bytes_for_valid_file() { + let dir = tempdir().unwrap(); + let path = dir.path().join("salt"); + fs::write(&path, [0u8; 32]).unwrap(); + let salt = load_salt(&path).unwrap(); + assert_eq!(salt, [0u8; 32]); + } + + #[test] + fn load_salt_errors_on_missing_file() { + let dir = tempdir().unwrap(); + let err = load_salt(&dir.path().join("missing")).unwrap_err(); + assert!(err.to_string().contains("not found") || err.to_string().contains("No such")); + } + + #[test] + fn load_salt_errors_on_wrong_length() { + let dir = tempdir().unwrap(); + let path = dir.path().join("short"); + fs::write(&path, [0u8; 16]).unwrap(); + let err = load_salt(&path).unwrap_err(); + assert!(err.to_string().contains("expected 32")); + } + + #[test] + fn generate_salt_creates_32_random_bytes() { + let dir = tempdir().unwrap(); + let path = dir.path().join("salt"); + generate_salt(&path).unwrap(); + let bytes = fs::read(&path).unwrap(); + assert_eq!(bytes.len(), 32); + // Two consecutive generations should differ (extremely high prob). + let path2 = dir.path().join("salt2"); + generate_salt(&path2).unwrap(); + let bytes2 = fs::read(&path2).unwrap(); + assert_ne!(bytes, bytes2); + } + + #[test] + fn generate_salt_refuses_to_overwrite_existing() { + let dir = tempdir().unwrap(); + let path = dir.path().join("salt"); + fs::write(&path, [0u8; 32]).unwrap(); + let err = generate_salt(&path).unwrap_err(); + assert!(err.to_string().contains("already exists")); + } +} +``` + +Add `tempfile = "3"` to `crates/feedback/Cargo.toml` `[dev-dependencies]`. + +- [ ] **Step 2: Run tests, expect compile failure** + +Run: `cargo test -p willow-feedback --lib salt::tests` +Expected: COMPILE FAIL — functions don't exist. + +- [ ] **Step 3: Implement load + generate** + +Append to `crates/feedback/src/salt.rs` *above* the test module: + +```rust +use std::fs; + +use anyhow::{anyhow, Context, Result}; +use rand::RngCore; + +/// Load a 32-byte salt from `path`. Errors if the file is missing, +/// the wrong length, or unreadable. +pub fn load_salt(path: &Path) -> Result<[u8; 32]> { + let bytes = fs::read(path) + .with_context(|| format!("failed to read salt file at {}", path.display()))?; + if bytes.len() != 32 { + return Err(anyhow!( + "salt file at {} is {} bytes, expected 32", + path.display(), + bytes.len() + )); + } + let mut out = [0u8; 32]; + out.copy_from_slice(&bytes); + Ok(out) +} + +/// Generate a fresh 32-byte salt and write it to `path`. Errors if +/// the file already exists (caller is expected to delete it first +/// for rotation). +pub fn generate_salt(path: &Path) -> Result<()> { + if path.exists() { + return Err(anyhow!( + "salt file at {} already exists; delete it first to rotate", + path.display() + )); + } + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent).ok(); + } + } + let mut salt = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut salt); + fs::write(path, salt) + .with_context(|| format!("failed to write salt file to {}", path.display()))?; + // Restrict permissions on Unix. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(path)?.permissions(); + perms.set_mode(0o600); + fs::set_permissions(path, perms)?; + } + Ok(()) +} +``` + +- [ ] **Step 4: Wire into `lib.rs`** + +```rust +pub mod sanitize; +pub mod wordlist; +pub mod handle; +pub mod ratelimit; +pub mod salt; +``` + +- [ ] **Step 5: Run tests** + +Run: `cargo test -p willow-feedback --lib salt::tests` +Expected: 5 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add crates/feedback/Cargo.toml crates/feedback/src/lib.rs crates/feedback/src/salt.rs +git commit -m "feat(feedback): salt file load + generate" +``` + +### Task 2.6: Startup-throttle gating (`throttle.rs`) + +**Files:** +- Create: `crates/feedback/src/throttle.rs` +- Modify: `crates/feedback/src/lib.rs` + +15-second startup throttle: atomic `O_CREAT | O_EXCL` on first boot; on subsequent boots, read mtime and sleep to enforce the gap. Bumps mtime via tempfile + rename. + +- [ ] **Step 1: Write the failing tests** + +Create `crates/feedback/src/throttle.rs`: + +```rust +//! Startup throttle. Enforces a minimum 15-second gap between +//! consecutive worker starts so a crash-loop attacker can't reset +//! rate-limit buckets unbounded times. + +use std::path::Path; +use std::time::Duration; + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn first_boot_creates_file_and_does_not_sleep() { + let dir = tempdir().unwrap(); + let path = dir.path().join(".feedback-last-boot"); + let elapsed = enforce_throttle(&path, Duration::from_secs(15)).unwrap(); + assert!(path.exists()); + assert!(elapsed < Duration::from_millis(500), "first boot should not sleep"); + } + + #[test] + fn second_boot_within_window_sleeps_remainder() { + let dir = tempdir().unwrap(); + let path = dir.path().join(".feedback-last-boot"); + // First call. + enforce_throttle(&path, Duration::from_millis(200)).unwrap(); + // Second call immediately — should sleep ≈ 200ms. + let elapsed = enforce_throttle(&path, Duration::from_millis(200)).unwrap(); + assert!(elapsed >= Duration::from_millis(150), "got {elapsed:?}"); + assert!(elapsed < Duration::from_millis(500)); + } + + #[test] + fn second_boot_outside_window_does_not_sleep() { + let dir = tempdir().unwrap(); + let path = dir.path().join(".feedback-last-boot"); + enforce_throttle(&path, Duration::from_millis(50)).unwrap(); + std::thread::sleep(Duration::from_millis(60)); + let elapsed = enforce_throttle(&path, Duration::from_millis(50)).unwrap(); + assert!(elapsed < Duration::from_millis(20), "got {elapsed:?}"); + } +} +``` + +- [ ] **Step 2: Run tests, expect compile failure** + +Run: `cargo test -p willow-feedback --lib throttle::tests` +Expected: COMPILE FAIL — `enforce_throttle` undefined. + +- [ ] **Step 3: Implement throttle** + +Append to `crates/feedback/src/throttle.rs` *above* the test module: + +```rust +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::time::{Instant, SystemTime}; + +use anyhow::{Context, Result}; +use filetime::{set_file_mtime, FileTime}; + +/// Enforce a minimum gap between consecutive boots by reading and +/// updating the mtime of `gate_path`. Returns the time spent inside +/// this call (mostly sleeping or zero). +/// +/// On first boot, atomically creates the file via `O_CREAT|O_EXCL`. +/// On subsequent boots, reads mtime; if `delta < window`, sleeps +/// `window - delta`; then bumps mtime via tempfile+rename. +pub fn enforce_throttle(gate_path: &Path, window: Duration) -> Result { + let start = Instant::now(); + match OpenOptions::new() + .write(true) + .create_new(true) + .open(gate_path) + { + Ok(mut f) => { + // First boot — write timestamp, no sleep. + let now = SystemTime::now(); + let secs = now + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or(Duration::ZERO) + .as_secs(); + f.write_all(secs.to_string().as_bytes()).ok(); + return Ok(start.elapsed()); + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + // Fall through to mtime check. + } + Err(e) => return Err(e).context("opening gate file"), + } + + // Read existing mtime. + let metadata = std::fs::metadata(gate_path).context("stat gate file")?; + let mtime = metadata.modified().context("read mtime")?; + let delta = SystemTime::now() + .duration_since(mtime) + .unwrap_or(Duration::ZERO); + if delta < window { + std::thread::sleep(window - delta); + } + + // Bump mtime atomically: write fresh contents to a sibling tempfile, rename over. + let parent = gate_path.parent().unwrap_or_else(|| Path::new(".")); + let tempfile = parent.join(format!( + ".{}.tmp", + gate_path.file_name().unwrap_or_default().to_string_lossy() + )); + let mut f = File::create(&tempfile).context("create tempfile")?; + let secs = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or(Duration::ZERO) + .as_secs(); + f.write_all(secs.to_string().as_bytes()).ok(); + drop(f); + std::fs::rename(&tempfile, gate_path).context("atomic rename")?; + let now = FileTime::from_system_time(SystemTime::now()); + set_file_mtime(gate_path, now).ok(); + + Ok(start.elapsed()) +} +``` + +- [ ] **Step 4: Wire into `lib.rs`** + +```rust +pub mod sanitize; +pub mod wordlist; +pub mod handle; +pub mod ratelimit; +pub mod salt; +pub mod throttle; +``` + +- [ ] **Step 5: Run tests** + +Run: `cargo test -p willow-feedback --lib throttle::tests` +Expected: 3 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add crates/feedback/src/lib.rs crates/feedback/src/throttle.rs +git commit -m "feat(feedback): startup-throttle gating with O_CREAT|O_EXCL" +``` + From bd45c8c3ede677f0ad2f2d98753957fe5d8b199f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 05:46:29 +0000 Subject: [PATCH 14/27] docs(plan): Phase 2 Task 2.7 (GitHub client + fixtures) --- docs/plans/2026-04-27-feedback-system.md | 363 +++++++++++++++++++++++ 1 file changed, 363 insertions(+) diff --git a/docs/plans/2026-04-27-feedback-system.md b/docs/plans/2026-04-27-feedback-system.md index 3bdfbaa3..03589474 100644 --- a/docs/plans/2026-04-27-feedback-system.md +++ b/docs/plans/2026-04-27-feedback-system.md @@ -1871,4 +1871,367 @@ git add crates/feedback/src/lib.rs crates/feedback/src/throttle.rs git commit -m "feat(feedback): startup-throttle gating with O_CREAT|O_EXCL" ``` +### Task 2.7: GitHub client trait + reqwest impl + fixtures (`github.rs`) + +**Files:** +- Create: `crates/feedback/src/github.rs` +- Create: `crates/feedback/tests/fixtures/github/201-created.json` +- Create: `crates/feedback/tests/fixtures/github/401-unauthorized.json` +- Create: `crates/feedback/tests/fixtures/github/403-secondary-rate-limit.json` +- Create: `crates/feedback/tests/fixtures/github/404-not-found.json` +- Create: `crates/feedback/tests/fixtures/github/422-validation.json` +- Modify: `crates/feedback/src/lib.rs` + +We isolate GitHub behind a trait so the role can be tested with a mock. The reqwest impl is thin — POST + parse response. + +- [ ] **Step 1: Capture the JSON fixtures** + +Capture representative responses. Each fixture is a static JSON document recorded once and committed; tests parse them as if they came back from `reqwest`. The shapes below are recorded from GitHub's REST API documentation (`docs.github.com/en/rest/issues/issues#create-an-issue`) — adjust if the live API drift requires it. + +`crates/feedback/tests/fixtures/github/201-created.json`: + +```json +{ + "url": "https://api.github.com/repos/intendednull/willow/issues/42", + "html_url": "https://github.com/intendednull/willow/issues/42", + "number": 42, + "state": "open", + "title": "[Bug] It crashes", + "body": "..." +} +``` + +`crates/feedback/tests/fixtures/github/422-validation.json`: + +```json +{ + "message": "Validation Failed", + "errors": [ + { "resource": "Issue", "code": "missing_field", "field": "title" } + ], + "documentation_url": "https://docs.github.com/rest/reference/issues#create-an-issue" +} +``` + +`crates/feedback/tests/fixtures/github/401-unauthorized.json`: + +```json +{ + "message": "Bad credentials", + "documentation_url": "https://docs.github.com/rest" +} +``` + +`crates/feedback/tests/fixtures/github/403-secondary-rate-limit.json`: + +```json +{ + "message": "You have exceeded a secondary rate limit. Please wait a few minutes before you try again.", + "documentation_url": "https://docs.github.com/rest/overview/resources-in-the-rest-api#secondary-rate-limits" +} +``` + +`crates/feedback/tests/fixtures/github/404-not-found.json`: + +```json +{ + "message": "Not Found", + "documentation_url": "https://docs.github.com/rest/reference/issues#create-an-issue" +} +``` + +- [ ] **Step 2: Write the failing tests** + +Create `crates/feedback/src/github.rs`: + +```rust +//! GitHub Issues API client. +//! +//! `GithubClient` is a trait so the role can be tested with a mock. +//! `ReqwestGithubClient` is the production impl. + +use willow_common::FeedbackErrReason; + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture(name: &str) -> serde_json::Value { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/github") + .join(name); + let raw = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read {}: {}", path.display(), e)); + serde_json::from_str(&raw).unwrap() + } + + #[test] + fn parse_201_extracts_html_url() { + let body = fixture("201-created.json"); + let url = parse_201_html_url(&body).unwrap(); + assert_eq!(url, "https://github.com/intendednull/willow/issues/42"); + } + + #[test] + fn parse_422_returns_invalid_input() { + let body = fixture("422-validation.json"); + match map_failure(422, None, Some(&body)) { + FeedbackErrReason::GithubFailure { status, message } => { + assert_eq!(status, 422); + assert_eq!(message.as_deref(), Some("Validation Failed")); + } + other => panic!("expected GithubFailure, got {other:?}"), + } + } + + #[test] + fn parse_401_returns_unconfigured() { + let body = fixture("401-unauthorized.json"); + // 401 is the role's signal to transition to Unconfigured; the + // GithubClient layer surfaces it as GithubFailure { status: 401 } + // and the role decides what to do with it. + match map_failure(401, None, Some(&body)) { + FeedbackErrReason::GithubFailure { status, .. } => assert_eq!(status, 401), + other => panic!("expected GithubFailure, got {other:?}"), + } + } + + #[test] + fn parse_403_with_zero_remaining_returns_rate_limited() { + // Headers carry the secondary-rate-limit signal. + let body = fixture("403-secondary-rate-limit.json"); + let headers = vec![ + ("x-ratelimit-remaining".to_string(), "0".to_string()), + ("retry-after".to_string(), "60".to_string()), + ]; + match map_failure(403, Some(&headers), Some(&body)) { + FeedbackErrReason::RateLimited { retry_after_ms } => { + assert_eq!(retry_after_ms, 60_000); + } + other => panic!("expected RateLimited, got {other:?}"), + } + } + + #[test] + fn parse_403_without_secondary_signal_is_just_failure() { + // A 403 *without* x-ratelimit-remaining: 0 is a generic 403, + // not the secondary-rate-limit signal. + let body = fixture("403-secondary-rate-limit.json"); + let headers = vec![("x-ratelimit-remaining".to_string(), "47".to_string())]; + match map_failure(403, Some(&headers), Some(&body)) { + FeedbackErrReason::GithubFailure { status, .. } => assert_eq!(status, 403), + other => panic!("expected GithubFailure, got {other:?}"), + } + } + + #[test] + fn message_truncation_caps_at_200_chars() { + let big = "x".repeat(500); + let body = serde_json::json!({ "message": big }); + match map_failure(500, None, Some(&body)) { + FeedbackErrReason::GithubFailure { message: Some(m), .. } => { + assert_eq!(m.chars().count(), 200); + } + other => panic!("expected GithubFailure, got {other:?}"), + } + } +} +``` + +- [ ] **Step 3: Run tests, expect compile failure** + +Run: `cargo test -p willow-feedback --lib github::tests` +Expected: COMPILE FAIL — `parse_201_html_url`, `map_failure` undefined. + +- [ ] **Step 4: Implement parsing helpers + the trait + reqwest impl** + +Append to `crates/feedback/src/github.rs` *above* the test module: + +```rust +use std::time::Duration; + +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use secrecy::ExposeSecret; +use secrecy::SecretString; +use serde::Serialize; + +/// Result of a `create_issue` call. Successful path carries the GitHub +/// `html_url` (the user-facing issue URL); failure carries a typed +/// reason matching `FeedbackErrReason` plus `secondary_rate_limit` +/// flag so the role can trip the worker-wide cooldown. +pub enum CreateIssueOutcome { + Created { html_url: String }, + Failed { reason: FeedbackErrReason }, +} + +#[async_trait] +pub trait GithubClient: Send + Sync { + /// Create an issue on the configured `owner/repo`. Returns the + /// resulting issue's `html_url` on success, or a typed error. + async fn create_issue(&self, body: IssueBody<'_>) -> CreateIssueOutcome; +} + +#[derive(Serialize)] +pub struct IssueBody<'a> { + pub title: &'a str, + pub body: &'a str, + pub labels: &'a [&'a str], +} + +/// Production GitHub client. +pub struct ReqwestGithubClient { + client: reqwest::Client, + repo: String, // "owner/repo" + token: SecretString, +} + +impl ReqwestGithubClient { + pub fn new(repo: String, token: SecretString) -> Result { + let client = reqwest::Client::builder() + .user_agent(concat!("willow-feedback/", env!("CARGO_PKG_VERSION"))) + .timeout(Duration::from_secs(20)) + .build()?; + Ok(Self { client, repo, token }) + } +} + +#[async_trait] +impl GithubClient for ReqwestGithubClient { + async fn create_issue(&self, body: IssueBody<'_>) -> CreateIssueOutcome { + let url = format!("https://api.github.com/repos/{}/issues", self.repo); + let resp = self + .client + .post(&url) + .bearer_auth(self.token.expose_secret()) + .header("Accept", "application/vnd.github+json") + .json(&body) + .send() + .await; + + let resp = match resp { + Ok(r) => r, + Err(e) if e.is_timeout() => { + return CreateIssueOutcome::Failed { + reason: FeedbackErrReason::GithubFailure { + status: 0, + message: Some("timeout".to_string()), + }, + }; + } + Err(e) => { + return CreateIssueOutcome::Failed { + reason: FeedbackErrReason::GithubFailure { + status: 0, + message: Some(format!("transport: {}", truncate(&e.to_string(), 200))), + }, + }; + } + }; + + let status = resp.status().as_u16(); + let headers: Vec<(String, String)> = resp + .headers() + .iter() + .map(|(k, v)| (k.as_str().to_lowercase(), v.to_str().unwrap_or("").to_string())) + .collect(); + let body_json: Option = resp.json().await.ok(); + + if status == 201 { + if let Some(b) = body_json.as_ref() { + if let Some(url) = parse_201_html_url(b) { + return CreateIssueOutcome::Created { html_url: url }; + } + } + return CreateIssueOutcome::Failed { + reason: FeedbackErrReason::GithubFailure { + status, + message: Some("missing html_url".to_string()), + }, + }; + } + + CreateIssueOutcome::Failed { + reason: map_failure(status, Some(&headers), body_json.as_ref()), + } + } +} + +/// Extract the `html_url` from a 201 response body. +pub fn parse_201_html_url(body: &serde_json::Value) -> Option { + body.get("html_url")?.as_str().map(|s| s.to_string()) +} + +/// Map a non-201 GitHub response to a `FeedbackErrReason`. +/// +/// Special cases: +/// - 403 with `x-ratelimit-remaining: 0` → `RateLimited` with +/// `retry-after` (default 60s if header missing). +/// - All other non-2xx → `GithubFailure { status, message }` with +/// the message truncated to 200 chars. +pub fn map_failure( + status: u16, + headers: Option<&[(String, String)]>, + body: Option<&serde_json::Value>, +) -> FeedbackErrReason { + if status == 403 { + if let Some(headers) = headers { + let remaining_zero = headers + .iter() + .any(|(k, v)| k == "x-ratelimit-remaining" && v == "0"); + if remaining_zero { + let retry_secs: u64 = headers + .iter() + .find(|(k, _)| k == "retry-after") + .and_then(|(_, v)| v.parse().ok()) + .unwrap_or(60); + return FeedbackErrReason::RateLimited { + retry_after_ms: retry_secs * 1000, + }; + } + } + } + let message = body + .and_then(|b| b.get("message").and_then(|m| m.as_str())) + .map(|s| truncate(s, 200)); + FeedbackErrReason::GithubFailure { status, message } +} + +fn truncate(s: &str, max_chars: usize) -> String { + s.chars().take(max_chars).collect() +} + +// Helper to make `anyhow!` resolve in the file even though we don't +// use it directly above (keeps the import non-noisy if `parse_*` is +// extended later). +#[allow(dead_code)] +fn _unused_anyhow() -> Result<()> { + Err(anyhow!("placeholder")) +} +``` + +- [ ] **Step 5: Wire into `lib.rs`** + +```rust +pub mod sanitize; +pub mod wordlist; +pub mod handle; +pub mod ratelimit; +pub mod salt; +pub mod throttle; +pub mod github; +``` + +- [ ] **Step 6: Run tests** + +Run: `cargo test -p willow-feedback --lib github::tests` +Expected: 6 tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add crates/feedback/ +git commit -m "feat(feedback): GitHub client trait + reqwest impl + fixtures" +``` + From 533a304298400a3ba131f6a1f5479aa49daafe3d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 05:48:06 +0000 Subject: [PATCH 15/27] docs(plan): Phase 2 Task 2.8 (FeedbackRole core) --- docs/plans/2026-04-27-feedback-system.md | 686 +++++++++++++++++++++++ 1 file changed, 686 insertions(+) diff --git a/docs/plans/2026-04-27-feedback-system.md b/docs/plans/2026-04-27-feedback-system.md index 03589474..9a0cc42c 100644 --- a/docs/plans/2026-04-27-feedback-system.md +++ b/docs/plans/2026-04-27-feedback-system.md @@ -2234,4 +2234,690 @@ git add crates/feedback/ git commit -m "feat(feedback): GitHub client trait + reqwest impl + fixtures" ``` +### Task 2.8: `FeedbackRole` (the integration piece) — `role.rs` + +**Files:** +- Create: `crates/feedback/src/role.rs` +- Modify: `crates/feedback/src/lib.rs` + +This is where everything composes. The role: + +1. Validates request shape (length caps). +2. Checks idempotency cache; if hit, returns cached URL. +3. Checks rate limits. +4. Computes salted handle. +5. Wraps body, sanitizes title, builds metadata block. +6. Calls `GithubClient::create_issue`. +7. Updates state machine (Unconfigured/cooldown transitions on 401/403). +8. Updates idempotency cache + counters. +9. Emits one structured log line. + +Tests inject a `MockGithubClient` so no live HTTP is hit. + +- [ ] **Step 1: Write the failing test scaffold** + +Create `crates/feedback/src/role.rs`: + +```rust +//! `FeedbackRole` — the integration glue. Implements `WorkerRole`. + +use std::collections::VecDeque; +use std::sync::Arc; + +use async_trait::async_trait; +use secrecy::SecretString; +use tokio::sync::Mutex; +use willow_common::{ + FeedbackCategory, FeedbackDiagnostics, FeedbackErrReason, WorkerRequest, WorkerResponse, + WorkerRole, WorkerRoleInfo, +}; +use willow_identity::EndpointId; +use willow_state::{Event, HeadsSummary}; + +use crate::github::{CreateIssueOutcome, GithubClient, IssueBody}; +use crate::handle::compute_handle; +use crate::ratelimit::{Clock, RateLimited, RateLimiter, SystemClock}; +use crate::sanitize::{sanitize_title, wrap_body_fenced}; + +#[cfg(test)] +mod tests; +``` + +- [ ] **Step 2: Create the test module skeleton with the failing tests** + +Create `crates/feedback/src/role/tests.rs` (Rust module-style — alternative is to keep tests in `role.rs`; we split into a sibling file because the test set is large). Actually, since `mod tests` is declared inline above, let's keep it as a test child module file. Use `#[path]` if needed. + +Replace the `#[cfg(test)] mod tests;` line with an inline `#[cfg(test)] mod tests {` block. Append at the bottom of `role.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use willow_identity::Identity; + + /// Test mock that returns a scripted response. + struct MockGithub { + outcomes: Mutex>, + calls: AtomicUsize, + } + impl MockGithub { + fn new(outcomes: Vec) -> Arc { + Arc::new(Self { + outcomes: Mutex::new(outcomes.into()), + calls: AtomicUsize::new(0), + }) + } + fn call_count(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } + } + #[async_trait] + impl GithubClient for MockGithub { + async fn create_issue(&self, _body: IssueBody<'_>) -> CreateIssueOutcome { + self.calls.fetch_add(1, Ordering::SeqCst); + self.outcomes + .lock() + .await + .pop_front() + .unwrap_or(CreateIssueOutcome::Failed { + reason: FeedbackErrReason::Unconfigured, + }) + } + } + + fn ok_outcome(url: &str) -> CreateIssueOutcome { + CreateIssueOutcome::Created { + html_url: url.to_string(), + } + } + + fn req(dedup: u8, body: &str) -> WorkerRequest { + WorkerRequest::Feedback { + dedup_id: [dedup; 16], + title: "title".to_string(), + category: FeedbackCategory::Bug, + body: body.to_string(), + diagnostics: None, + } + } + + fn role_with(github: Arc) -> FeedbackRole { + FeedbackRole::new_for_test(FeedbackRoleConfig { + github, + salt: [0u8; 32], + per_peer_per_hour: 5, + global_per_hour: 50, + repo: "intendednull/willow".to_string(), + }) + } + + fn signer() -> EndpointId { + Identity::generate().endpoint_id() + } + + #[tokio::test] + async fn happy_path_returns_feedback_ok() { + let mock = MockGithub::new(vec![ok_outcome("https://github.com/x/y/issues/1")]); + let mut role = role_with(mock.clone()); + let resp = role.handle_request(signer(), req(1, "hi")).await; + assert!(matches!( + resp, + WorkerResponse::FeedbackOk { issue_url } if issue_url == "https://github.com/x/y/issues/1" + )); + assert_eq!(mock.call_count(), 1); + } + + #[tokio::test] + async fn validates_title_length() { + let mock = MockGithub::new(vec![]); + let mut role = role_with(mock.clone()); + let req = WorkerRequest::Feedback { + dedup_id: [0u8; 16], + title: "x".repeat(201), + category: FeedbackCategory::Bug, + body: "ok".to_string(), + diagnostics: None, + }; + match role.handle_request(signer(), req).await { + WorkerResponse::FeedbackErr { + reason: FeedbackErrReason::InvalidInput { field, .. }, + } => assert_eq!(field, "title"), + other => panic!("expected InvalidInput(title), got {other:?}"), + } + assert_eq!(mock.call_count(), 0); + } + + #[tokio::test] + async fn validates_body_length() { + let mock = MockGithub::new(vec![]); + let mut role = role_with(mock.clone()); + match role + .handle_request(signer(), req(0, &"x".repeat(8001))) + .await + { + WorkerResponse::FeedbackErr { + reason: FeedbackErrReason::InvalidInput { field, .. }, + } => assert_eq!(field, "body"), + other => panic!("expected InvalidInput(body), got {other:?}"), + } + } + + #[tokio::test] + async fn validates_other_detail_length() { + let mock = MockGithub::new(vec![]); + let mut role = role_with(mock.clone()); + let req = WorkerRequest::Feedback { + dedup_id: [0u8; 16], + title: "t".to_string(), + category: FeedbackCategory::Other { + detail: Some("x".repeat(61)), + }, + body: "ok".to_string(), + diagnostics: None, + }; + match role.handle_request(signer(), req).await { + WorkerResponse::FeedbackErr { + reason: FeedbackErrReason::InvalidInput { field, .. }, + } => assert_eq!(field, "category.detail"), + other => panic!("expected InvalidInput(category.detail), got {other:?}"), + } + } + + #[tokio::test] + async fn unconfigured_when_no_github_client() { + let mut role = FeedbackRole::new_unconfigured(FeedbackRoleConfig { + github: MockGithub::new(vec![]), + salt: [0u8; 32], + per_peer_per_hour: 5, + global_per_hour: 50, + repo: "intendednull/willow".to_string(), + }); + assert!(matches!( + role.handle_request(signer(), req(0, "hi")).await, + WorkerResponse::FeedbackErr { + reason: FeedbackErrReason::Unconfigured + } + )); + } + + #[tokio::test] + async fn per_peer_rate_limit_kicks_in() { + let outs = (0..5).map(|i| ok_outcome(&format!("https://x/{i}"))).collect(); + let mock = MockGithub::new(outs); + let mut role = role_with(mock.clone()); + let p = signer(); + for i in 0..5 { + let r = role.handle_request(p, req(i as u8, "ok")).await; + assert!(matches!(r, WorkerResponse::FeedbackOk { .. })); + } + match role.handle_request(p, req(255, "ok")).await { + WorkerResponse::FeedbackErr { + reason: FeedbackErrReason::RateLimited { .. }, + } => {} + other => panic!("expected RateLimited, got {other:?}"), + } + assert_eq!(mock.call_count(), 5); + } + + #[tokio::test] + async fn idempotency_cache_returns_cached_url() { + let mock = MockGithub::new(vec![ok_outcome("https://github.com/x/y/issues/9")]); + let mut role = role_with(mock.clone()); + let p = signer(); + let r1 = role.handle_request(p, req(7, "hi")).await; + let r2 = role.handle_request(p, req(7, "hi")).await; + assert_eq!(format!("{r1:?}"), format!("{r2:?}")); + assert_eq!(mock.call_count(), 1); + } + + #[tokio::test] + async fn distinct_signers_with_same_dedup_get_distinct_urls() { + let mock = MockGithub::new(vec![ + ok_outcome("https://github.com/x/y/issues/1"), + ok_outcome("https://github.com/x/y/issues/2"), + ]); + let mut role = role_with(mock.clone()); + let r1 = role.handle_request(signer(), req(7, "hi")).await; + let r2 = role.handle_request(signer(), req(7, "hi")).await; + match (&r1, &r2) { + ( + WorkerResponse::FeedbackOk { issue_url: u1 }, + WorkerResponse::FeedbackOk { issue_url: u2 }, + ) => assert_ne!(u1, u2), + _ => panic!("expected two FeedbackOk, got {r1:?} / {r2:?}"), + } + assert_eq!(mock.call_count(), 2); + } + + #[tokio::test] + async fn fourzeroone_transitions_to_unconfigured() { + let mock = MockGithub::new(vec![CreateIssueOutcome::Failed { + reason: FeedbackErrReason::GithubFailure { + status: 401, + message: Some("Bad credentials".to_string()), + }, + }]); + let mut role = role_with(mock.clone()); + let p = signer(); + // First call surfaces the 401. + let r1 = role.handle_request(p, req(0, "hi")).await; + assert!(matches!( + r1, + WorkerResponse::FeedbackErr { + reason: FeedbackErrReason::Unconfigured + } + )); + // Subsequent calls are also Unconfigured without contacting the mock. + let r2 = role.handle_request(p, req(1, "hi")).await; + assert!(matches!( + r2, + WorkerResponse::FeedbackErr { + reason: FeedbackErrReason::Unconfigured + } + )); + assert_eq!(mock.call_count(), 1); + } + + #[tokio::test] + async fn fourohthree_secondary_trips_cooldown() { + let mock = MockGithub::new(vec![ + CreateIssueOutcome::Failed { + reason: FeedbackErrReason::RateLimited { + retry_after_ms: 60_000, + }, + }, + ok_outcome("https://x/1"), // wouldn't be called during cooldown + ]); + let mut role = role_with(mock.clone()); + let r1 = role.handle_request(signer(), req(0, "hi")).await; + assert!(matches!( + r1, + WorkerResponse::FeedbackErr { + reason: FeedbackErrReason::RateLimited { .. } + } + )); + // Second call returns RateLimited from the cooldown without contacting the mock. + let r2 = role.handle_request(signer(), req(1, "hi")).await; + assert!(matches!( + r2, + WorkerResponse::FeedbackErr { + reason: FeedbackErrReason::RateLimited { .. } + } + )); + assert_eq!(mock.call_count(), 1); + } + + #[tokio::test] + async fn role_info_reports_feedback_with_counters() { + let mock = MockGithub::new(vec![ok_outcome("https://x/1")]); + let mut role = role_with(mock); + role.handle_request(signer(), req(0, "hi")).await; + match role.role_info() { + WorkerRoleInfo::Feedback { + reports_accepted, + reports_rejected, + .. + } => { + assert_eq!(reports_accepted, 1); + assert_eq!(reports_rejected, 0); + } + other => panic!("expected Feedback, got {other:?}"), + } + } +} +``` + +- [ ] **Step 3: Implement `FeedbackRole`** + +Append to `crates/feedback/src/role.rs` *between* the `use` block and the `#[cfg(test)] mod tests` block: + +```rust +const TITLE_MAX: usize = 200; +const BODY_MAX: usize = 8000; +const DETAIL_MAX: usize = 60; +const DEDUP_CACHE_CAPACITY: usize = 4096; + +pub struct FeedbackRoleConfig { + pub github: Arc, + pub salt: [u8; 32], + pub per_peer_per_hour: u32, + pub global_per_hour: u32, + pub repo: String, +} + +/// Internal state machine for the role's GitHub-side health. +enum GithubState { + Configured, + /// 401 transitions here permanently for the rest of the process. + Unconfigured, + /// 403 secondary-rate-limit; while in cooldown, all requests + /// reply RateLimited with this `retry_after_ms` (decremented by + /// elapsed time on each request). + Cooldown { + until: std::time::Instant, + retry_after_ms: u64, + }, +} + +pub struct FeedbackRole { + github: Arc, + salt: [u8; 32], + repo: String, + state: GithubState, + rate_limiter: RateLimiter, + clock: SystemClock, + /// LRU-ish: deque + companion vec. Capacity 4096 entries. + /// Each entry: (signer, dedup_id, issue_url). + dedup_cache: VecDeque<((EndpointId, [u8; 16]), String)>, + reports_accepted: u64, + reports_rejected: u64, +} + +impl FeedbackRole { + pub fn new(config: FeedbackRoleConfig) -> Self { + let mut clock = SystemClock; + let rate_limiter = RateLimiter::new( + config.per_peer_per_hour, + config.global_per_hour, + &mut clock, + ); + Self { + github: config.github, + salt: config.salt, + repo: config.repo, + state: GithubState::Configured, + rate_limiter, + clock, + dedup_cache: VecDeque::with_capacity(DEDUP_CACHE_CAPACITY), + reports_accepted: 0, + reports_rejected: 0, + } + } + + /// Construct a role that's permanently `Unconfigured` (used by + /// the dev stack when no GITHUB_TOKEN is set). + pub fn new_unconfigured(config: FeedbackRoleConfig) -> Self { + let mut role = Self::new(config); + role.state = GithubState::Unconfigured; + role + } + + #[cfg(test)] + pub fn new_for_test(config: FeedbackRoleConfig) -> Self { + Self::new(config) + } + + fn validate_request(req: &WorkerRequest) -> Result<(), FeedbackErrReason> { + let WorkerRequest::Feedback { + title, + body, + category, + .. + } = req + else { + return Err(FeedbackErrReason::InvalidInput { + field: "request".to_string(), + message: "not a feedback request".to_string(), + }); + }; + if title.is_empty() || title.len() > TITLE_MAX { + return Err(FeedbackErrReason::InvalidInput { + field: "title".to_string(), + message: format!("must be 1..={TITLE_MAX} bytes"), + }); + } + if body.is_empty() || body.len() > BODY_MAX { + return Err(FeedbackErrReason::InvalidInput { + field: "body".to_string(), + message: format!("must be 1..={BODY_MAX} bytes"), + }); + } + if let FeedbackCategory::Other { + detail: Some(detail), + } = category + { + if detail.len() > DETAIL_MAX { + return Err(FeedbackErrReason::InvalidInput { + field: "category.detail".to_string(), + message: format!("must be 0..={DETAIL_MAX} bytes"), + }); + } + } + Ok(()) + } + + fn lookup_cache(&self, key: &(EndpointId, [u8; 16])) -> Option { + self.dedup_cache + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.clone()) + } + + fn record_cache(&mut self, key: (EndpointId, [u8; 16]), url: String) { + if self.dedup_cache.len() >= DEDUP_CACHE_CAPACITY { + self.dedup_cache.pop_front(); + } + self.dedup_cache.push_back((key, url)); + } + + fn assemble_issue<'a>( + &self, + signer: &EndpointId, + title: &'a str, + category: &FeedbackCategory, + body: &str, + diagnostics: Option<&FeedbackDiagnostics>, + ) -> (String, String, Vec<&'static str>) { + let handle = compute_handle(&self.salt, signer); + let title_clean = sanitize_title(title); + let prefix = match category { + FeedbackCategory::Bug => "[Bug] ".to_string(), + FeedbackCategory::Suggestion => "[Suggestion] ".to_string(), + FeedbackCategory::Other { detail: None } => "[Other] ".to_string(), + FeedbackCategory::Other { detail: Some(d) } => { + format!("[Other:{}] ", sanitize_title(d)) + } + }; + let mut full_title = format!("{prefix}{title_clean}"); + if full_title.chars().count() > 256 { + // Truncate to 255 chars + ellipsis (256 total). + let mut t: String = full_title.chars().take(255).collect(); + t.push('…'); + full_title = t; + } + + let category_str = match category { + FeedbackCategory::Bug => "Bug".to_string(), + FeedbackCategory::Suggestion => "Suggestion".to_string(), + FeedbackCategory::Other { detail: None } => "Other".to_string(), + FeedbackCategory::Other { detail: Some(d) } => format!("Other ({d})"), + }; + let mut header = format!( + "**Reporter (salted hash):** `{handle}`\n**Category:** {category_str}\n", + ); + if let Some(d) = diagnostics { + header.push_str(&format!("**App version:** {}\n", d.app_version)); + if let Some(b) = &d.build_hash { + header.push_str(&format!("**Build:** {b}\n")); + } + if let Some(l) = &d.locale { + header.push_str(&format!("**Locale:** {l}\n")); + } + header.push_str(&format!("**Client:** {:?}\n", d.client)); + } else { + header.push_str("(diagnostics not provided)\n"); + } + let preamble = "\n> Submitted via willow-feedback. The reporter's body is rendered \n> verbatim in the fenced block below; @mentions, links, and image\n> syntax inside it are **not** processed by GitHub.\n\n"; + let body_block = wrap_body_fenced(body); + let full_body = format!("{header}{preamble}{body_block}"); + + let labels: Vec<&'static str> = match category { + FeedbackCategory::Bug => vec!["feedback", "feedback:bug", "feedback:triage"], + FeedbackCategory::Suggestion => { + vec!["feedback", "feedback:suggestion", "feedback:triage"] + } + FeedbackCategory::Other { .. } => vec!["feedback", "feedback:other", "feedback:triage"], + }; + (full_title, full_body, labels) + } +} + +#[async_trait] +impl WorkerRole for FeedbackRole { + fn role_info(&self) -> WorkerRoleInfo { + WorkerRoleInfo::Feedback { + reports_accepted: self.reports_accepted, + reports_rejected: self.reports_rejected, + currently_rate_limited: self.rate_limiter.currently_rate_limited(&self.clock), + global_rate_limited: self.rate_limiter.global_is_throttled(&self.clock), + } + } + + fn on_event(&mut self, _event: &Event) { + // Feedback role doesn't track DAG events. + } + + async fn handle_request( + &mut self, + signer: EndpointId, + req: WorkerRequest, + ) -> WorkerResponse { + // 1. Unconfigured short-circuit. + if matches!(self.state, GithubState::Unconfigured) { + self.reports_rejected += 1; + return WorkerResponse::FeedbackErr { + reason: FeedbackErrReason::Unconfigured, + }; + } + + // 2. Cooldown short-circuit. + if let GithubState::Cooldown { until, retry_after_ms } = self.state { + if std::time::Instant::now() < until { + self.reports_rejected += 1; + let remaining = until.saturating_duration_since(std::time::Instant::now()); + return WorkerResponse::FeedbackErr { + reason: FeedbackErrReason::RateLimited { + retry_after_ms: remaining.as_millis() as u64, + }, + }; + } else { + self.state = GithubState::Configured; + let _ = retry_after_ms; // silence unused warning + } + } + + // 3. Validate shape. + if let Err(reason) = Self::validate_request(&req) { + self.reports_rejected += 1; + return WorkerResponse::FeedbackErr { reason }; + } + let WorkerRequest::Feedback { + dedup_id, + title, + category, + body, + diagnostics, + } = req + else { + unreachable!("validated above"); + }; + + // 4. Idempotency cache. + let cache_key = (signer, dedup_id); + if let Some(url) = self.lookup_cache(&cache_key) { + return WorkerResponse::FeedbackOk { issue_url: url }; + } + + // 5. Rate limit. + match self.rate_limiter.try_take(&signer, &mut self.clock) { + Ok(()) => {} + Err(RateLimited::PerPeer { retry_after_ms }) + | Err(RateLimited::Global { retry_after_ms }) => { + self.reports_rejected += 1; + return WorkerResponse::FeedbackErr { + reason: FeedbackErrReason::RateLimited { retry_after_ms }, + }; + } + } + + // 6. Assemble + post. + let (full_title, full_body, labels) = + self.assemble_issue(&signer, &title, &category, &body, diagnostics.as_ref()); + let outcome = self + .github + .create_issue(IssueBody { + title: &full_title, + body: &full_body, + labels: &labels, + }) + .await; + + match outcome { + CreateIssueOutcome::Created { html_url } => { + self.record_cache(cache_key, html_url.clone()); + self.reports_accepted += 1; + WorkerResponse::FeedbackOk { + issue_url: html_url, + } + } + CreateIssueOutcome::Failed { + reason: FeedbackErrReason::GithubFailure { status: 401, .. }, + } => { + self.state = GithubState::Unconfigured; + self.reports_rejected += 1; + WorkerResponse::FeedbackErr { + reason: FeedbackErrReason::Unconfigured, + } + } + CreateIssueOutcome::Failed { + reason: FeedbackErrReason::RateLimited { retry_after_ms }, + } => { + self.state = GithubState::Cooldown { + until: std::time::Instant::now() + std::time::Duration::from_millis(retry_after_ms), + retry_after_ms, + }; + self.reports_rejected += 1; + WorkerResponse::FeedbackErr { + reason: FeedbackErrReason::RateLimited { retry_after_ms }, + } + } + CreateIssueOutcome::Failed { reason } => { + self.reports_rejected += 1; + WorkerResponse::FeedbackErr { reason } + } + } + } + + fn heads_summaries(&self) -> Vec<(String, HeadsSummary)> { + vec![] + } +} +``` + +- [ ] **Step 4: Wire into `lib.rs`** + +```rust +pub mod sanitize; +pub mod wordlist; +pub mod handle; +pub mod ratelimit; +pub mod salt; +pub mod throttle; +pub mod github; +pub mod role; +``` + +- [ ] **Step 5: Run all tests** + +Run: `cargo test -p willow-feedback --lib` +Expected: every module's tests pass — sanitize, wordlist, handle, ratelimit, salt, throttle, github, role. + +- [ ] **Step 6: Commit** + +```bash +git add crates/feedback/ +git commit -m "feat(feedback): FeedbackRole — sanitization, rate limits, idempotency, cooldown" +``` + From b1db28a9420d296a31e99dcf641e7d8943050c24 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 05:49:02 +0000 Subject: [PATCH 16/27] docs(plan): Phase 2 Task 2.9 (main binary) --- docs/plans/2026-04-27-feedback-system.md | 235 +++++++++++++++++++++++ 1 file changed, 235 insertions(+) diff --git a/docs/plans/2026-04-27-feedback-system.md b/docs/plans/2026-04-27-feedback-system.md index 9a0cc42c..2293a982 100644 --- a/docs/plans/2026-04-27-feedback-system.md +++ b/docs/plans/2026-04-27-feedback-system.md @@ -2920,4 +2920,239 @@ git add crates/feedback/ git commit -m "feat(feedback): FeedbackRole — sanitization, rate limits, idempotency, cooldown" ``` +### Task 2.9: `main.rs` — CLI, repo validation, IrohNetwork, runtime + +**Files:** +- Modify: `crates/feedback/src/main.rs` + +Closely mirrors `crates/storage/src/main.rs`'s shape, with extra steps for salt and the `--generate-salt` CLI flag. + +- [ ] **Step 1: Replace the placeholder `main.rs` with the real binary** + +Replace `crates/feedback/src/main.rs`: + +```rust +//! Willow Feedback Node — proxies user feedback to GitHub issues. + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{anyhow, Context, Result}; +use clap::Parser; +use regex::Regex; +use secrecy::SecretString; +use willow_feedback::github::{GithubClient, ReqwestGithubClient}; +use willow_feedback::role::{FeedbackRole, FeedbackRoleConfig}; +use willow_feedback::salt::{generate_salt, load_salt}; +use willow_feedback::throttle::enforce_throttle; + +#[derive(Parser)] +#[command(name = "willow-feedback", about = "Willow feedback worker node")] +struct Cli { + /// Path to the Ed25519 identity keypair file. + #[arg(long, default_value = "/etc/willow/feedback.key")] + identity_path: String, + + /// Iroh relay URL to connect through. + #[arg(long)] + relay_url: Option, + + /// GitHub PAT (`Issues: write` scope, fine-grained, target repo only). + /// Can also be supplied via the `GITHUB_TOKEN` env var. + #[arg(long, env = "GITHUB_TOKEN")] + github_token: Option, + + /// `owner/repo` to file issues against. + #[arg(long, env = "FEEDBACK_REPO", default_value = "intendednull/willow")] + github_repo: String, + + /// Per-peer rate limit (requests / hour). + #[arg(long, default_value = "5")] + rate_limit_per_hour: u32, + + /// Worker-wide rate limit (requests / hour). + #[arg(long, default_value = "50")] + global_rate_limit_per_hour: u32, + + /// Path to the 32-byte reporter-handle salt file. + #[arg(long, default_value = "/etc/willow/feedback-salt")] + reporter_salt_file: PathBuf, + + /// Generate a new identity at `--identity-path` and exit. + #[arg(long)] + generate_identity: bool, + + /// Write 32 random bytes to `--reporter-salt-file` if missing and exit. + #[arg(long)] + generate_salt: bool, + + /// Print the bech32 peer ID for `--identity-path` and exit. + #[arg(long)] + print_peer_id: bool, +} + +fn validate_repo(repo: &str) -> Result<()> { + static RE: std::sync::OnceLock = std::sync::OnceLock::new(); + let re = RE.get_or_init(|| Regex::new(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$").unwrap()); + if !re.is_match(repo) { + return Err(anyhow!( + "invalid FEEDBACK_REPO {:?}: must match owner/repo (alphanumeric, dot, underscore, hyphen)", + repo + )); + } + Ok(()) +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), + ) + .init(); + + let cli = Cli::parse(); + + if cli.generate_identity { + willow_worker::identity::generate_identity(&cli.identity_path)?; + tracing::info!("identity generated at {}", cli.identity_path); + return Ok(()); + } + + if cli.generate_salt { + generate_salt(&cli.reporter_salt_file)?; + tracing::info!("salt generated at {}", cli.reporter_salt_file.display()); + return Ok(()); + } + + if cli.print_peer_id { + return willow_worker::identity::print_peer_id(&cli.identity_path); + } + + validate_repo(&cli.github_repo)?; + + // Enforce 15-second startup throttle. + let identity_dir = std::path::Path::new(&cli.identity_path) + .parent() + .unwrap_or_else(|| std::path::Path::new("/etc/willow")) + .to_path_buf(); + std::fs::create_dir_all(&identity_dir).ok(); + let gate = identity_dir.join(".feedback-last-boot"); + let throttled = enforce_throttle(&gate, Duration::from_secs(15)) + .context("startup throttle")?; + if !throttled.is_zero() { + tracing::info!("startup throttle slept for {:?}", throttled); + } + + // Load identity. + let identity = willow_worker::identity::load_or_generate(&cli.identity_path)?; + + // Load salt (auto-generate if missing — entrypoint may not have run --generate-salt). + if !cli.reporter_salt_file.exists() { + generate_salt(&cli.reporter_salt_file) + .with_context(|| format!("generating salt at {}", cli.reporter_salt_file.display()))?; + tracing::info!("salt generated at {}", cli.reporter_salt_file.display()); + } + let salt = load_salt(&cli.reporter_salt_file)?; + + // Resolve relay URL. + let relay_url = cli.relay_url.as_deref().map(|url| { + url.parse::() + .expect("invalid relay URL") + }); + + let iroh_config = willow_network::iroh::Config { + secret_key: identity.secret_key().clone(), + relay_url, + bootstrap_peers: vec![], + mdns: false, + }; + let network = willow_network::iroh::IrohNetwork::new(iroh_config).await?; + + // Build the role. If no GITHUB_TOKEN, run permanently Unconfigured + // (dev path: every UI flow exercised, no GitHub calls). + let role = match cli.github_token.as_deref() { + Some(token) if !token.is_empty() => { + let client: Arc = Arc::new(ReqwestGithubClient::new( + cli.github_repo.clone(), + SecretString::from(token.to_string()), + )?); + FeedbackRole::new(FeedbackRoleConfig { + github: client, + salt, + per_peer_per_hour: cli.rate_limit_per_hour, + global_per_hour: cli.global_rate_limit_per_hour, + repo: cli.github_repo.clone(), + }) + } + _ => { + tracing::warn!("no GITHUB_TOKEN set; running permanently Unconfigured"); + FeedbackRole::new_unconfigured(FeedbackRoleConfig { + github: Arc::new(NullGithubClient) as Arc, + salt, + per_peer_per_hour: cli.rate_limit_per_hour, + global_per_hour: cli.global_rate_limit_per_hour, + repo: cli.github_repo.clone(), + }) + } + }; + + let config = willow_worker::WorkerConfig { + identity_path: cli.identity_path, + relay_url: cli.relay_url, + sync_interval_secs: 60, + allocation: willow_worker::AllocationStrategy::Global, + }; + + willow_worker::runtime::run(Box::new(role), config, network).await +} + +/// Stub GithubClient that's never called (used when running Unconfigured). +struct NullGithubClient; +#[async_trait::async_trait] +impl GithubClient for NullGithubClient { + async fn create_issue( + &self, + _body: willow_feedback::github::IssueBody<'_>, + ) -> willow_feedback::github::CreateIssueOutcome { + willow_feedback::github::CreateIssueOutcome::Failed { + reason: willow_common::FeedbackErrReason::Unconfigured, + } + } +} +``` + +- [ ] **Step 2: Add `willow-common` to `dev-dependencies` if needed** + +`crates/feedback/Cargo.toml` already lists `willow-common`. The `NullGithubClient` impl uses `willow_common::FeedbackErrReason`; that's already accessible. + +- [ ] **Step 3: Verify the binary compiles** + +Run: `cargo build -p willow-feedback` +Expected: pass. + +- [ ] **Step 4: Sanity test the CLI subcommands** + +Run: `cargo run -p willow-feedback -- --help 2>&1 | head -30` +Expected: prints help text including `--generate-identity`, `--generate-salt`, `--print-peer-id`, `--github-token`, `--github-repo`, `--reporter-salt-file`. + +Run: `cargo run -p willow-feedback -- --github-repo "javascript:alert(1)" --github-token x 2>&1 | head -5` +Expected: errors with `invalid FEEDBACK_REPO`. Confirms repo validation. + +- [ ] **Step 5: Run the full crate test suite + workspace check** + +Run: `cargo test -p willow-feedback` +Expected: every test passes. + +Run: `just check-native 2>&1 | tail -10` +Expected: workspace-wide cargo check passes. + +- [ ] **Step 6: Commit** + +```bash +git add crates/feedback/src/main.rs +git commit -m "feat(feedback): main binary — CLI, repo validation, runtime bring-up" +``` + From 27746b574dbb463ce3b9fa4578134f41333260e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 05:50:46 +0000 Subject: [PATCH 17/27] docs(plan): Phase 3 (client API) tasks 3.1-3.2 --- docs/plans/2026-04-27-feedback-system.md | 486 +++++++++++++++++++++++ 1 file changed, 486 insertions(+) diff --git a/docs/plans/2026-04-27-feedback-system.md b/docs/plans/2026-04-27-feedback-system.md index 2293a982..3c2f0db3 100644 --- a/docs/plans/2026-04-27-feedback-system.md +++ b/docs/plans/2026-04-27-feedback-system.md @@ -3155,4 +3155,490 @@ git add crates/feedback/src/main.rs git commit -m "feat(feedback): main binary — CLI, repo validation, runtime bring-up" ``` +--- + +## Phase 3: `willow-client` API + +**Why now:** the worker is reachable via the existing gossip request/response pathway. We need a typed client API that constructs `WorkerRequest::Feedback` and parses `WorkerResponse::FeedbackOk/FeedbackErr`. + +### Task 3.1: Add `feedback_worker` to `ClientConfig` + `FeedbackError` enum + +**Files:** +- Modify: `crates/client/src/lib.rs` (around line 189 — `ClientConfig`) +- Create: `crates/client/src/feedback.rs` +- Modify: `crates/client/Cargo.toml` (add `url`, `rand`) + +- [ ] **Step 1: Add the config field** + +Edit `crates/client/src/lib.rs` — extend `ClientConfig` (currently around line 189) and the `Default` impl: + +```rust +pub struct ClientConfig { + pub relay_addr: Option, + pub display_name: Option, + pub persistence: bool, + pub bootstrap_peers: Vec, + /// Project-run feedback worker peer ID. If `None`, the + /// in-app feedback form is disabled and renders a + /// "Feedback is not configured for this build" state. + pub feedback_worker: Option, +} + +impl Default for ClientConfig { + fn default() -> Self { + Self { + relay_addr: None, + display_name: None, + persistence: true, + bootstrap_peers: vec![], + feedback_worker: None, + } + } +} +``` + +- [ ] **Step 2: Add `url` and `rand` to client deps** + +Add to `crates/client/Cargo.toml` `[dependencies]`: + +```toml +url = "2" +rand = { version = "0.8", features = ["std", "std_rng"] } +``` + +(Workspace may already pin `rand`; use the workspace version if so.) + +- [ ] **Step 3: Create the empty `feedback.rs` module** + +Create `crates/client/src/feedback.rs` with just the error enum + skeleton, so it compiles before tests are added: + +```rust +//! `Client::submit_feedback` and the `FeedbackError` enum. + +#[derive(Debug, thiserror::Error, Clone, PartialEq)] +pub enum FeedbackError { + #[error("client is not connected to a network")] + NotConnected, + #[error("feedback worker is not configured for this build")] + NotConfigured, + #[error("feedback worker is unreachable")] + WorkerUnreachable, + #[error("request timed out")] + Timeout, + #[error("rate limited; retry after {retry_after_ms}ms")] + RateLimited { retry_after_ms: u64 }, + #[error("invalid input in field {field}: {message}")] + InvalidInput { field: String, message: String }, + #[error("github returned {status}: {message:?}")] + GithubFailure { + status: u16, + message: Option, + }, + /// Inner string is the worker-supplied URL, truncated to 512 + /// chars on receipt to bound error formatting. + #[error("worker returned a malformed issue url: {0}")] + BadIssueUrl(String), + #[error("internal: {0}")] + Internal(String), +} +``` + +- [ ] **Step 4: Wire the module into `lib.rs`** + +Add to the `pub mod ...` declarations (alphabetically): + +```rust +pub mod feedback; +``` + +- [ ] **Step 5: Verify it compiles** + +Run: `cargo check -p willow-client` +Expected: pass. + +- [ ] **Step 6: Commit** + +```bash +git add crates/client/Cargo.toml crates/client/src/lib.rs crates/client/src/feedback.rs +git commit -m "feat(client): add feedback_worker config + FeedbackError" +``` + +### Task 3.2: `Client::submit_feedback` (TDD with `MemNetwork`) + +**Files:** +- Modify: `crates/client/src/feedback.rs` (add `submit_feedback` method on `ClientHandle`) +- Create: `crates/client/src/tests/feedback.rs` +- Modify: `crates/client/src/tests/mod.rs` (or wherever the test module list lives) + +- [ ] **Step 1: Inspect the existing pattern for sending a `WorkerRequest`** + +Run: `grep -rn "WorkerRequest::Sync\|WorkerWireMessage::Request" crates/client/src/ | head -10` +Expected: existing `submit_sync_request` / `submit_history_request` methods (or similar) that build `WorkerRequest`, wrap in `WorkerWireMessage::Request`, gossip on `_willow_workers`, await a matching response. The implementer follows that exact shape. + +If no such pattern exists in `willow-client` (the request path may live in worker_cache or another module), look at `crates/client/src/worker_cache.rs` and follow its conventions. + +- [ ] **Step 2: Write the failing test** + +Create `crates/client/src/tests/feedback.rs`: + +```rust +//! Client-tier integration tests for Client::submit_feedback. +//! Uses MemNetwork to stand up a mock feedback worker. + +use std::time::Duration; +use willow_client::feedback::FeedbackError; +use willow_client::{ClientConfig, ClientHandle}; +use willow_common::{ + FeedbackCategory, FeedbackErrReason, WorkerRequest, WorkerResponse, WorkerWireMessage, +}; +use willow_network::mem::MemNetwork; + +// The exact spawn helper depends on the existing test infra; reuse +// the helper that other client tests use (e.g. `test_client()` from +// crates/client/src/tests/multi_peer_sync.rs or similar). + +#[tokio::test] +async fn submit_feedback_returns_not_configured_when_unset() { + let (client, _evloop) = make_client(None).await; + let err = client + .submit_feedback( + "title".to_string(), + FeedbackCategory::Bug, + "body".to_string(), + false, + ) + .await + .unwrap_err(); + assert_eq!(err, FeedbackError::NotConfigured); +} + +#[tokio::test] +async fn submit_feedback_happy_path_returns_parsed_url() { + let (client, worker_peer_id, _evloop_c, _evloop_w, _net) = + spawn_client_and_mock_worker(|req| match req { + WorkerRequest::Feedback { .. } => WorkerResponse::FeedbackOk { + issue_url: "https://github.com/x/y/issues/42".to_string(), + }, + _ => panic!("unexpected request"), + }) + .await; + let url = client + .submit_feedback( + "title".to_string(), + FeedbackCategory::Bug, + "body".to_string(), + false, + ) + .await + .unwrap(); + assert_eq!(url.as_str(), "https://github.com/x/y/issues/42"); + let _ = worker_peer_id; +} + +#[tokio::test] +async fn submit_feedback_maps_rate_limited() { + let (client, _peer, _e1, _e2, _n) = spawn_client_and_mock_worker(|_req| { + WorkerResponse::FeedbackErr { + reason: FeedbackErrReason::RateLimited { retry_after_ms: 12_345 }, + } + }) + .await; + let err = client + .submit_feedback( + "t".to_string(), + FeedbackCategory::Bug, + "b".to_string(), + false, + ) + .await + .unwrap_err(); + assert_eq!(err, FeedbackError::RateLimited { retry_after_ms: 12_345 }); +} + +#[tokio::test] +async fn submit_feedback_maps_bad_issue_url() { + let (client, _peer, _e1, _e2, _n) = spawn_client_and_mock_worker(|_req| { + WorkerResponse::FeedbackOk { + issue_url: "not a url".to_string(), + } + }) + .await; + let err = client + .submit_feedback( + "t".to_string(), + FeedbackCategory::Bug, + "b".to_string(), + false, + ) + .await + .unwrap_err(); + matches!(err, FeedbackError::BadIssueUrl(_)); +} + +#[tokio::test] +async fn submit_feedback_returns_worker_unreachable_when_no_listener() { + let fake_peer = willow_identity::Identity::generate().endpoint_id(); + let (client, _ev) = make_client(Some(fake_peer)).await; + // No worker spawned; the request times out / returns unreachable. + let result = tokio::time::timeout( + Duration::from_secs(5), + client.submit_feedback( + "t".to_string(), + FeedbackCategory::Bug, + "b".to_string(), + false, + ), + ) + .await; + assert!(result.is_ok(), "client must surface error within 5s"); + let err = result.unwrap().unwrap_err(); + assert!( + matches!( + err, + FeedbackError::WorkerUnreachable | FeedbackError::Timeout + ), + "got {err:?}" + ); +} + +// --- helpers --------------------------------------------------------------- + +async fn make_client( + feedback_worker: Option, +) -> (ClientHandle, /* event loop join handle */ tokio::task::JoinHandle<()>) { + let cfg = ClientConfig { + feedback_worker, + persistence: false, + ..Default::default() + }; + let identity = willow_identity::Identity::generate(); + // The exact API for spawning a ClientHandle in tests is project-specific — + // the implementer follows the pattern in crates/client/src/tests/*.rs, + // typically `ClientHandle::::new_with(cfg, identity, network)` + // or similar. Returns (client, event_loop_handle). + todo!("follow existing test_client() helper from crates/client/src/tests/") +} + +async fn spawn_client_and_mock_worker( + handler: impl Fn(&WorkerRequest) -> WorkerResponse + Send + Sync + 'static, +) -> ( + ClientHandle, + willow_identity::EndpointId, + tokio::task::JoinHandle<()>, + tokio::task::JoinHandle<()>, + std::sync::Arc, +) { + todo!("reuse the multi-peer test harness from crates/client/src/tests/multi_peer_sync.rs") +} +``` + +The two `todo!()` helpers MUST be filled in by following the existing client test harness. The plan can't write them here because the project's helper signatures are project-internal. **The implementer's first concrete action under Step 3 is to read `crates/client/src/tests/multi_peer_sync.rs` (or whichever sibling file currently contains the helpers) and copy-adapt the spawn pattern.** + +- [ ] **Step 3: Wire the test module into the test list** + +Edit `crates/client/src/tests/mod.rs` (or `crates/client/src/lib.rs`'s `#[cfg(test)] mod tests` declaration if that's the convention) to add `mod feedback;`. + +- [ ] **Step 4: Run the tests, expect compile failure on `submit_feedback`** + +Run: `cargo test -p willow-client --test feedback 2>&1 | head -20` (or whatever the test invocation pattern is — `cargo test -p willow-client feedback::` if the test module is inline). +Expected: COMPILE FAIL — `submit_feedback` undefined on `ClientHandle`. + +- [ ] **Step 5: Implement `Client::submit_feedback`** + +Append to `crates/client/src/feedback.rs`: + +```rust +use std::time::Duration; + +use rand::RngCore; +use willow_common::{ + FeedbackCategory, FeedbackDiagnostics, FeedbackErrReason, WorkerRequest, WorkerResponse, + WorkerWireMessage, +}; +use willow_identity::EndpointId; + +const SUBMIT_TIMEOUT: Duration = Duration::from_secs(30); + +impl crate::ClientHandle { + /// Submit feedback to the configured feedback worker. + pub async fn submit_feedback( + &self, + title: String, + category: FeedbackCategory, + body: String, + include_diagnostics: bool, + ) -> Result { + let Some(worker) = self.feedback_worker_peer() else { + return Err(FeedbackError::NotConfigured); + }; + + // Generate a fresh dedup_id per call. Callers that need to + // retry idempotently can use submit_feedback_with_dedup_id. + let mut dedup_id = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut dedup_id); + + let diagnostics = if include_diagnostics { + Some(build_diagnostics()) + } else { + None + }; + + let req = WorkerRequest::Feedback { + dedup_id, + title, + category, + body, + diagnostics, + }; + + // Submit through the same worker request/response path used + // by replay/history requests. The exact helper name varies; + // the pattern is `self.send_worker_request(worker, req).await`. + let resp = match tokio::time::timeout( + SUBMIT_TIMEOUT, + self.send_worker_request(worker, req), + ) + .await + { + Ok(Ok(r)) => r, + Ok(Err(e)) => return Err(map_send_err(e)), + Err(_) => return Err(FeedbackError::Timeout), + }; + + match resp { + WorkerResponse::FeedbackOk { issue_url } => { + let truncated = if issue_url.chars().count() > 512 { + issue_url.chars().take(512).collect() + } else { + issue_url + }; + url::Url::parse(&truncated).map_err(|_| FeedbackError::BadIssueUrl(truncated)) + } + WorkerResponse::FeedbackErr { reason } => Err(map_reason(reason)), + other => Err(FeedbackError::Internal(format!( + "unexpected response shape: {other:?}" + ))), + } + } + + fn feedback_worker_peer(&self) -> Option { + // Implementer wires this to the field stored from + // ClientConfig::feedback_worker at construction. + self.config.feedback_worker + } +} + +fn map_reason(r: FeedbackErrReason) -> FeedbackError { + match r { + FeedbackErrReason::RateLimited { retry_after_ms } => { + FeedbackError::RateLimited { retry_after_ms } + } + FeedbackErrReason::InvalidInput { field, message } => FeedbackError::InvalidInput { + field: truncate(&field, 64), + message: truncate(&message, 200), + }, + FeedbackErrReason::GithubFailure { status, message } => FeedbackError::GithubFailure { + status, + message: message.map(|m| truncate(&m, 200)), + }, + FeedbackErrReason::Unconfigured => FeedbackError::NotConfigured, + } +} + +fn map_send_err(e: impl std::fmt::Display) -> FeedbackError { + let s = e.to_string(); + if s.contains("unreachable") || s.contains("no listener") { + FeedbackError::WorkerUnreachable + } else { + FeedbackError::Internal(truncate(&s, 200)) + } +} + +fn truncate(s: &str, max: usize) -> String { + s.chars().take(max).collect() +} + +fn build_diagnostics() -> FeedbackDiagnostics { + use willow_common::ClientPlatform; + let app_version = env!("CARGO_PKG_VERSION").to_string(); + let build_hash = option_env!("WILLOW_BUILD_SHA").map(|s| s.to_string()); + #[cfg(target_arch = "wasm32")] + let (locale, client) = wasm_diagnostics(); + #[cfg(not(target_arch = "wasm32"))] + let (locale, client) = native_diagnostics(); + FeedbackDiagnostics { + app_version, + build_hash, + locale, + client, + } +} + +#[cfg(target_arch = "wasm32")] +fn wasm_diagnostics() -> (Option, willow_common::ClientPlatform) { + use willow_common::ClientPlatform; + // Implementer wires this to web_sys / js_sys to read + // `navigator.language` and parse the UA string into a coarse + // family/major-version. See spec §"FeedbackDiagnostics". + let locale = web_sys::window() + .and_then(|w| w.navigator().language()); + let ua = web_sys::window() + .map(|w| w.navigator().user_agent().unwrap_or_default()) + .unwrap_or_default(); + let ua_family = parse_ua_family(&ua); + (locale, ClientPlatform::Web { ua_family }) +} + +#[cfg(target_arch = "wasm32")] +fn parse_ua_family(ua: &str) -> String { + // Coarse parser: pick `firefox/` etc. Implementer keeps + // it conservative; falls back to "unknown/0" if no match. + for (needle, name) in [ + ("Firefox/", "firefox"), + ("Chrome/", "chrome"), + ("Safari/", "safari"), + ("Edge/", "edge"), + ] { + if let Some(idx) = ua.find(needle) { + let rest = &ua[idx + needle.len()..]; + let major: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + if !major.is_empty() { + return format!("{name}/{major}"); + } + } + } + "unknown/0".to_string() +} + +#[cfg(not(target_arch = "wasm32"))] +fn native_diagnostics() -> (Option, willow_common::ClientPlatform) { + use willow_common::ClientPlatform; + let locale = std::env::var("LANG").ok().and_then(|l| { + // Strip ".UTF-8" or "@variant" suffixes to leave bare BCP 47. + l.split(['.', '@']).next().map(|s| s.replace('_', "-")) + }); + ( + locale, + ClientPlatform::Native { + os: std::env::consts::OS.to_string(), + arch: std::env::consts::ARCH.to_string(), + }, + ) +} +``` + +The implementer adapts `self.send_worker_request(...)` to the existing project helper for sending a worker request and awaiting a typed response. If no such helper exists, use the same pattern as `crates/client/src/worker_cache.rs` — wrap in `WorkerWireMessage::Request { request_id, target_peer, payload }`, broadcast, listen for the matching `Response`. + +- [ ] **Step 6: Run tests** + +Run: `cargo test -p willow-client feedback` +Expected: 4 tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add crates/client/ +git commit -m "feat(client): Client::submit_feedback + FeedbackError mapping" +``` + From a725476f7d41361be4d074b420803317ee405098 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 05:52:07 +0000 Subject: [PATCH 18/27] docs(plan): Phase 4 (web UI) tasks 4.1-4.2 --- docs/plans/2026-04-27-feedback-system.md | 336 +++++++++++++++++++++++ 1 file changed, 336 insertions(+) diff --git a/docs/plans/2026-04-27-feedback-system.md b/docs/plans/2026-04-27-feedback-system.md index 3c2f0db3..9170e8e5 100644 --- a/docs/plans/2026-04-27-feedback-system.md +++ b/docs/plans/2026-04-27-feedback-system.md @@ -3641,4 +3641,340 @@ git add crates/client/ git commit -m "feat(client): Client::submit_feedback + FeedbackError mapping" ``` +--- + +## Phase 4: Web UI + +**Why now:** the client API is callable. We render the modal, wire it to `Client::submit_feedback`, and add the entry point to the settings panel. Browser tests (`wasm-pack test --headless --firefox`) verify the rendering and event behavior; the multi-peer scenario lives in the client-tier tests already written. + +### Task 4.1: Add the `Help` settings tab + +**Files:** +- Modify: `crates/web/src/state.rs` (extend `SettingsTab` enum) +- Modify: `crates/web/src/components/settings.rs` (add the new tab + body) +- Create: `crates/web/src/components/feedback.rs` (modal component) +- Modify: `crates/web/src/components/mod.rs` (`pub mod feedback`) + +- [ ] **Step 1: Extend `SettingsTab`** + +In `crates/web/src/state.rs` (around line 26), add a `Help` variant: + +```rust +pub enum SettingsTab { + #[default] + Profile, + Server, + Roles, + Presence, + Notifications, + /// "Help & Feedback" — shows the Send Feedback button + privacy notes. + Help, +} +``` + +The display-name match in `crates/web/src/components/settings.rs` (around line 40) gets a new arm: `SettingsTab::Help => "Help"`. + +- [ ] **Step 2: Add a stub feedback component** + +Create `crates/web/src/components/feedback.rs`: + +```rust +//! Help & Feedback settings panel + modal. + +use leptos::prelude::*; +use willow_client::feedback::FeedbackError; +use willow_common::FeedbackCategory; + +use crate::app::WebClientHandle; + +/// Feedback panel rendered inside SettingsPanel when SettingsTab::Help +/// is active. Always renders the GitHub-direct fallback link; opens +/// the modal on Send Feedback click iff a feedback worker is configured. +#[component] +pub fn FeedbackPanel(repo: String, configured: ReadSignal) -> impl IntoView { + let (modal_open, set_modal_open) = signal(false); + view! { + + } +} + +#[component] +fn FeedbackModal( + repo: String, + on_close: impl Fn() + Send + Sync + Clone + 'static, +) -> impl IntoView { + // State machine for the modal. + // Implementer fills in the form fields, validation, dedup_id + // retention, submit handler. Driven by the per-state copy table + // in the spec §"Failure-state copy". + todo!("see Task 4.2") +} + +/// Build the GitHub-direct fallback URL. `repo` is hardcoded +/// `https://github.com/` prefix + validated `owner/repo`. Title and +/// body are url-encoded. +pub fn fallback_link(repo: &str, title: &str, body: &str) -> String { + let repo_validated = if is_valid_repo(repo) { + repo + } else { + "intendednull/willow" + }; + format!( + "https://github.com/{}/issues/new?title={}&body={}", + repo_validated, + urlencoding::encode(title), + urlencoding::encode(body), + ) +} + +fn is_valid_repo(repo: &str) -> bool { + let mut parts = repo.split('/'); + let owner = parts.next(); + let name = parts.next(); + if parts.next().is_some() || owner.is_none() || name.is_none() { + return false; + } + let valid = |s: &str| { + !s.is_empty() + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + }; + valid(owner.unwrap()) && valid(name.unwrap()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fallback_link_url_encodes_title_and_body() { + let link = fallback_link("intendednull/willow", "title&with=evil", "body with spaces"); + assert!(link.contains("title%26with%3Devil")); + assert!(link.contains("body%20with%20spaces")); + } + + #[test] + fn fallback_link_rejects_invalid_repo() { + // Mis-set repo falls back to the canonical default rather than + // becoming an XSS vector. + let link = fallback_link("javascript:alert(1)", "t", "b"); + assert!(link.starts_with("https://github.com/intendednull/willow/issues/new?")); + } + + #[test] + fn is_valid_repo_accepts_canonical() { + assert!(is_valid_repo("intendednull/willow")); + assert!(is_valid_repo("a.b/c-d_e")); + } + + #[test] + fn is_valid_repo_rejects_bad_input() { + assert!(!is_valid_repo("javascript:alert(1)")); + assert!(!is_valid_repo("")); + assert!(!is_valid_repo("noslash")); + assert!(!is_valid_repo("a/b/c")); + } +} +``` + +Add `urlencoding = "2"` to `crates/web/Cargo.toml` `[dependencies]`. + +- [ ] **Step 3: Wire `feedback` into `crates/web/src/components/mod.rs`** + +```rust +pub mod feedback; +pub use feedback::FeedbackPanel; +``` + +- [ ] **Step 4: Run the unit tests for the helper functions** + +Run: `cargo test -p willow-web --lib feedback::tests` +Expected: 4 tests pass (these are pure-Rust unit tests on the helper, not browser tests). + +- [ ] **Step 5: Add the new tab in `SettingsPanel`** + +In `crates/web/src/components/settings.rs`, find the existing tab-rendering switch (around line 117 where `
` opens). Add a `Help` arm that renders ``. The `repo` is constant (`"intendednull/willow"` hard-coded — the worker validates it; the web side is just a display string for the fallback link). The `configured` signal is true iff `app_state.feedback_worker_configured` (a new boolean signal you add in step 6). + +Sketch: + +```rust +SettingsTab::Help => view! { + +}.into_any(), +``` + +- [ ] **Step 6: Add `feedback_worker_configured` signal to `AppState`** + +In `crates/web/src/state.rs`, add a `feedback_worker_configured: ReadSignal` field on `AppState`. The signal is derived from whether the parsed `__WILLOW_FEEDBACK_PEER_ID` is `Some`. Initial value is set during client construction (Task 5.x). + +- [ ] **Step 7: Verify it compiles** + +Run: `just check-wasm` (or `cargo check -p willow-web --target wasm32-unknown-unknown`). +Expected: pass. + +- [ ] **Step 8: Commit** + +```bash +git add crates/web/Cargo.toml crates/web/src/components/feedback.rs crates/web/src/components/mod.rs crates/web/src/components/settings.rs crates/web/src/state.rs +git commit -m "feat(web): Help & Feedback settings tab + fallback link" +``` + +### Task 4.2: Modal component (form, states, retry semantics) + +**Files:** +- Modify: `crates/web/src/components/feedback.rs` +- Create: `crates/web/tests/feedback_modal.rs` (browser test) + +This is the form. State machine is `Idle → Submitting → Success | Failure`. `dedup_id` is held in local component state and only regenerates on "Send another" / form-clear; "Retry" preserves it. + +- [ ] **Step 1: Write the failing browser test** + +Create `crates/web/tests/feedback_modal.rs` (or extend `crates/web/tests/browser.rs` if that's the project pattern). The exact harness is project-specific; reuse the helper that mounts a component and queries the DOM. Each test's intent: + +```rust +//! Browser tests for the feedback modal. + +use wasm_bindgen_test::*; +wasm_bindgen_test_configure!(run_in_browser); + +#[wasm_bindgen_test] +fn modal_renders_form_fields() { + // Mount FeedbackModal with a mock submit handler. + // Assert: title input, category dropdown, body textarea, + // diagnostics checkbox, submit button, cancel button all present. + todo!("follow crates/web/tests/browser.rs harness") +} + +#[wasm_bindgen_test] +fn submit_disabled_when_title_empty() { + todo!() +} + +#[wasm_bindgen_test] +fn other_category_reveals_detail_input() { + todo!() +} + +#[wasm_bindgen_test] +fn diagnostics_disclosure_renders_exact_value() { + todo!() +} + +#[wasm_bindgen_test] +fn rate_limited_failure_shows_retry_after_minutes() { + // Mock client returns FeedbackError::RateLimited { retry_after_ms: 720_000 } + // Assert: failure inline text mentions "12 minutes". + todo!() +} + +#[wasm_bindgen_test] +fn send_another_clears_dedup_id() { + todo!() +} + +#[wasm_bindgen_test] +fn retry_preserves_dedup_id() { + todo!() +} +``` + +- [ ] **Step 2: Run the browser tests, verify failure** + +Run: `wasm-pack test --headless --firefox crates/web --test feedback_modal` +Expected: FAIL — modal is `todo!()`. + +- [ ] **Step 3: Implement the modal** + +Replace the `todo!()` in `FeedbackModal` in `crates/web/src/components/feedback.rs` with a full implementation. Key pieces: + +1. Local signals for `title`, `body`, `category`, `category_detail`, `include_diagnostics`, `state`, `dedup_id`. + +2. `dedup_id` is initialized to a fresh random `[u8; 16]` (use `getrandom`) and stored in a `RwSignal` so it persists across `Submitting → Failure → Submitting` retries. Cleared by "Send another". + +3. Submit handler: + + ```rust + let handle = use_context::().unwrap(); + let on_submit = move || { + set_state.set(ModalState::Submitting); + let title = title.get(); + let body = body.get(); + let category = match category.get().as_str() { + "bug" => FeedbackCategory::Bug, + "suggestion" => FeedbackCategory::Suggestion, + _ => FeedbackCategory::Other { + detail: { + let d = category_detail.get(); + if d.is_empty() { None } else { Some(d) } + }, + }, + }; + let include_diag = include_diagnostics.get(); + let dedup = dedup_id.get(); + leptos::task::spawn_local(async move { + // Use the dedup-aware variant that takes the prebuilt dedup_id. + let result = handle + .submit_feedback_with_dedup( + dedup, + title, + category, + body, + include_diag, + ) + .await; + match result { + Ok(url) => set_state.set(ModalState::Success(url.to_string())), + Err(e) => set_state.set(ModalState::Failure(e)), + } + }); + }; + ``` + + This requires a small extension to the client API: `submit_feedback_with_dedup` that takes a caller-provided `[u8; 16]`. Add it to `crates/client/src/feedback.rs` (right above `submit_feedback`); have `submit_feedback` call into it after generating the random ID. + +4. Render the failure-state copy table from the spec verbatim. Each `FeedbackError` variant maps to one of the documented inline copy strings. + +5. Render the privacy note below the Submit button unconditionally (italic; spec §"Web UI" exact text). + +6. Render the diagnostics disclosure: when the checkbox is checked, show a `
` block listing `app_version`, `build_hash` if any, `locale`, and the `ClientPlatform` debug rendering — exactly the value that will be sent. + +- [ ] **Step 4: Run browser tests, verify all pass** + +Run: `just test-browser` +Expected: all 7 modal tests pass plus all pre-existing browser tests. + +- [ ] **Step 5: Commit** + +```bash +git add crates/client/src/feedback.rs crates/web/src/components/feedback.rs crates/web/tests/ +git commit -m "feat(web): feedback modal — form, states, dedup retention, failure copy" +``` + From f9c89823260f4c6a633f82376669f87c45c8af0f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 05:53:22 +0000 Subject: [PATCH 19/27] docs(plan): Phase 5 (deployment + dev plumbing) tasks 5.1-5.6 --- docs/plans/2026-04-27-feedback-system.md | 427 +++++++++++++++++++++++ 1 file changed, 427 insertions(+) diff --git a/docs/plans/2026-04-27-feedback-system.md b/docs/plans/2026-04-27-feedback-system.md index 9170e8e5..62aeb1f1 100644 --- a/docs/plans/2026-04-27-feedback-system.md +++ b/docs/plans/2026-04-27-feedback-system.md @@ -3977,4 +3977,431 @@ git add crates/client/src/feedback.rs crates/web/src/components/feedback.rs crat git commit -m "feat(web): feedback modal — form, states, dedup retention, failure copy" ``` +--- + +## Phase 5: Deployment + dev plumbing + +**Why now:** logic works in unit + browser tests. We need the dev stack to actually start the worker, surface its peer ID to the served web app, and give the production deployment a path to inject the peer ID at container start. + +### Task 5.1: `init.js` placeholder substitution + dev fetch + +**Files:** +- Modify: `crates/web/init.js` +- Modify: `crates/web/index.html` (add ``) +- Create: `crates/web/dev_assets/.gitignore` (single line: `feedback-peer-id.txt`) +- Create: `crates/web/dev_assets/.gitkeep` (empty) + +- [ ] **Step 1: Replace `crates/web/init.js`** + +```javascript +(function() { + var theme = localStorage.getItem('willow-theme') || 'dark'; + document.documentElement.setAttribute('data-theme', theme); + + // Production placeholder substitution. The web container's + // entrypoint runs `sed` over /usr/share/nginx/html/init.js to + // replace these tokens with env-injected values. If the + // placeholder still equals the literal token string, the env + // var was unset — we delete the global so the app treats the + // feature as not-configured. + window.__WILLOW_RELAY_URL = window.__WILLOW_RELAY_URL || "__INJECT_RELAY_URL__"; + window.__WILLOW_FEEDBACK_PEER_ID = window.__WILLOW_FEEDBACK_PEER_ID || "__INJECT_FEEDBACK_PEER_ID__"; + if (window.__WILLOW_RELAY_URL === "__INJECT_RELAY_URL__") delete window.__WILLOW_RELAY_URL; + if (window.__WILLOW_FEEDBACK_PEER_ID === "__INJECT_FEEDBACK_PEER_ID__") delete window.__WILLOW_FEEDBACK_PEER_ID; + + // Dev defaults: auto-detect localhost relay, fetch dev feedback peer ID. + var h = location.hostname; + var isDev = (h === '127.0.0.1' || h === 'localhost'); + if (isDev) { + if (!window.__WILLOW_RELAY_URL) { + window.__WILLOW_RELAY_URL = 'http://' + h + ':3340'; + } + if (!window.__WILLOW_FEEDBACK_PEER_ID) { + // dev_assets/feedback-peer-id.txt is written by scripts/dev.sh + // and copied into dist/ by trunk's data-trunk copy-dir directive. + fetch('/dev_assets/feedback-peer-id.txt') + .then(function(r) { return r.ok ? r.text() : ''; }) + .then(function(s) { + var id = s.trim(); + if (id) window.__WILLOW_FEEDBACK_PEER_ID = id; + }) + .catch(function() { /* ignore — form will render NotConfigured */ }); + } + } +})(); +``` + +- [ ] **Step 2: Add the `` directive to `index.html`** + +In `crates/web/index.html`, just before the existing `` line (around line 22), add: + +```html + +``` + +- [ ] **Step 3: Create the dev_assets directory contents** + +```bash +mkdir -p crates/web/dev_assets +echo 'feedback-peer-id.txt' > crates/web/dev_assets/.gitignore +touch crates/web/dev_assets/.gitkeep +``` + +- [ ] **Step 4: Verify a dev build produces the expected output** + +Run: `cd crates/web && trunk build` +Expected: builds successfully. `ls dist/dev_assets/` should show `.gitkeep` (and any test files you placed there). + +- [ ] **Step 5: Commit** + +```bash +git add crates/web/index.html crates/web/init.js crates/web/dev_assets/ +git commit -m "feat(web): init.js placeholder substitution + dev_assets dir" +``` + +### Task 5.2: Web container entrypoint + +**Files:** +- Create: `docker/web-entrypoint.sh` +- Modify: `docker/web.Dockerfile` + +- [ ] **Step 1: Create `docker/web-entrypoint.sh`** + +```sh +#!/bin/sh +set -e + +INIT_JS=/usr/share/nginx/html/init.js + +if [ -n "$WILLOW_RELAY_URL" ]; then + # Escape | so URLs containing pipes can't break the sed substitution. + safe=$(printf '%s' "$WILLOW_RELAY_URL" | sed 's/|/\\|/g') + sed -i "s|__INJECT_RELAY_URL__|$safe|g" "$INIT_JS" +fi +if [ -n "$WILLOW_FEEDBACK_PEER_ID" ]; then + safe=$(printf '%s' "$WILLOW_FEEDBACK_PEER_ID" | sed 's/|/\\|/g') + sed -i "s|__INJECT_FEEDBACK_PEER_ID__|$safe|g" "$INIT_JS" +fi + +exec nginx -g 'daemon off;' +``` + +`chmod +x docker/web-entrypoint.sh` after creating. + +- [ ] **Step 2: Modify `docker/web.Dockerfile`** + +Append to the existing `FROM nginx:alpine` stage (after the `EXPOSE 80` line): + +```dockerfile +COPY docker/web-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh +ENTRYPOINT ["/docker-entrypoint.sh"] +``` + +- [ ] **Step 3: Smoke test (optional, requires Docker)** + +```bash +docker build -f docker/web.Dockerfile -t willow-web-test . +docker run --rm -e WILLOW_FEEDBACK_PEER_ID=did:wpid:test123 willow-web-test \ + cat /usr/share/nginx/html/init.js | grep test123 +``` + +Expected: the substituted peer ID appears in the served `init.js`. + +- [ ] **Step 4: Commit** + +```bash +git add docker/web-entrypoint.sh docker/web.Dockerfile +git commit -m "feat(docker): web entrypoint substitutes WILLOW_RELAY_URL/FEEDBACK_PEER_ID" +``` + +### Task 5.3: Feedback worker Dockerfile + entrypoint + +**Files:** +- Create: `docker/feedback.Dockerfile` +- Create: `docker/feedback-entrypoint.sh` + +- [ ] **Step 1: Create `docker/feedback.Dockerfile`** + +Mirror `docker/replay.Dockerfile`: + +```dockerfile +FROM rust:latest AS builder +WORKDIR /build +COPY . . +RUN cargo build --release -p willow-feedback + +FROM rust:slim +COPY --from=builder /build/target/release/willow-feedback /usr/local/bin/willow-feedback +COPY docker/feedback-entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] +``` + +- [ ] **Step 2: Create `docker/feedback-entrypoint.sh`** + +```sh +#!/bin/sh +set -e +mkdir -p /etc/willow + +# Generate identity if needed. +if [ ! -f /etc/willow/feedback.key ]; then + willow-feedback --generate-identity --identity-path /etc/willow/feedback.key +fi + +# Generate salt if needed. +if [ ! -f /etc/willow/feedback-salt ]; then + willow-feedback --generate-salt --reporter-salt-file /etc/willow/feedback-salt +fi + +# Wait for relay peer ID to be published. +echo "Waiting for relay peer ID..." +while [ ! -f /shared/relay-peer-id ]; do sleep 1; done +RELAY_PEER_ID=$(cat /shared/relay-peer-id) +echo "Relay peer ID: $RELAY_PEER_ID" + +RELAY_ADDR="/dns4/relay/tcp/9091/ws/p2p/$RELAY_PEER_ID" + +exec willow-feedback \ + --identity-path /etc/willow/feedback.key \ + --reporter-salt-file /etc/willow/feedback-salt \ + --relay-url "$RELAY_ADDR" \ + --rate-limit-per-hour "${RATE_LIMIT_PER_HOUR:-5}" \ + --global-rate-limit-per-hour "${GLOBAL_RATE_LIMIT_PER_HOUR:-50}" +``` + +`chmod +x docker/feedback-entrypoint.sh`. + +- [ ] **Step 3: Commit** + +```bash +git add docker/feedback.Dockerfile docker/feedback-entrypoint.sh +git commit -m "feat(docker): feedback worker Dockerfile + entrypoint" +``` + +### Task 5.4: docker-compose service + +**Files:** +- Modify: `docker-compose.yml` + +- [ ] **Step 1: Add the `feedback` service** + +Insert into `docker-compose.yml` (between the existing `storage-2` and `web` services): + +```yaml + feedback: + build: + context: . + dockerfile: docker/feedback.Dockerfile + volumes: + - feedback-identity:/etc/willow + - shared:/shared:ro + environment: + - GITHUB_TOKEN=${GITHUB_TOKEN} + - FEEDBACK_REPO=${FEEDBACK_REPO:-intendednull/willow} + - RATE_LIMIT_PER_HOUR=5 + - GLOBAL_RATE_LIMIT_PER_HOUR=50 + - RUST_LOG=info,willow_feedback=debug + depends_on: + - relay + restart: on-failure:5 +``` + +Append to the `volumes:` block: + +```yaml + feedback-identity: +``` + +The web service also needs the feedback peer ID. Add an environment block (or extend the existing one if any) to the `web:` service: + +```yaml + web: + # ... existing config ... + environment: + - WILLOW_FEEDBACK_PEER_ID=${WILLOW_FEEDBACK_PEER_ID:-} + - WILLOW_RELAY_URL=${WILLOW_RELAY_URL:-} +``` + +The deployment operator sets `WILLOW_FEEDBACK_PEER_ID` in `.env` after running `just docker-ids`. + +- [ ] **Step 2: Verify the compose file is valid** + +Run: `docker compose config -q` +Expected: pass. + +- [ ] **Step 3: Commit** + +```bash +git add docker-compose.yml +git commit -m "feat(docker-compose): add feedback service + web env wiring" +``` + +### Task 5.5: `scripts/dev.sh` integration + +**Files:** +- Modify: `scripts/dev.sh` + +- [ ] **Step 1: Inspect existing service-add pattern** + +Read the existing replay/storage blocks in `scripts/dev.sh`. The pattern: + +```sh +SERVICE_IDENTITY="$DEV_DIR/.key" +echo -e "${COLOR}Starting node...${NC}" +cargo run -p willow- -- ... > "$LOG_DIR/.log" 2>&1 & +PIDS+=("$!") +tail -f "$LOG_DIR/.log" 2>/dev/null | while IFS= read -r line; do + echo -e "${COLOR}[]${NC} $line" +done & +``` + +- [ ] **Step 2: Add the feedback worker after the storage block** + +Insert into `scripts/dev.sh` between the storage launch and the web launch: + +```sh +# --- Feedback ---------------------------------------------------------------- + +FEEDBACK_IDENTITY="$DEV_DIR/feedback.key" +FEEDBACK_SALT="$DEV_DIR/feedback-salt" + +# First-run idempotent generation. +if [ ! -f "$FEEDBACK_IDENTITY" ]; then + cargo run -q -p willow-feedback -- \ + --identity-path "$FEEDBACK_IDENTITY" \ + --generate-identity +fi +if [ ! -f "$FEEDBACK_SALT" ]; then + cargo run -q -p willow-feedback -- \ + --reporter-salt-file "$FEEDBACK_SALT" \ + --generate-salt +fi + +# Print peer ID to dev_assets/feedback-peer-id.txt so the web build serves it. +mkdir -p "$ROOT/crates/web/dev_assets" +cargo run -q -p willow-feedback -- \ + --identity-path "$FEEDBACK_IDENTITY" \ + --print-peer-id \ + > "$ROOT/crates/web/dev_assets/feedback-peer-id.txt" +echo -e "${GREEN}Feedback peer ID written to crates/web/dev_assets/feedback-peer-id.txt${NC}" + +# Cyan label. +CYAN='\033[0;36m' +echo -e "${CYAN}Starting feedback node...${NC}" +cargo run -p willow-feedback -- \ + --identity-path "$FEEDBACK_IDENTITY" \ + --reporter-salt-file "$FEEDBACK_SALT" \ + --relay-url "$RELAY_URL" \ + > "$LOG_DIR/feedback.log" 2>&1 & +PIDS+=("$!") + +tail -f "$LOG_DIR/feedback.log" 2>/dev/null | while IFS= read -r line; do + echo -e "${CYAN}[feedback]${NC} $line" +done & +PIDS+=("$!") +``` + +(`RELAY_URL` is the variable the existing replay/storage blocks read; if your `dev.sh` uses a different name, follow the existing pattern.) + +The feedback worker runs **without** `GITHUB_TOKEN` in dev — every successful path replies `Unconfigured`, exercising the UI plumbing without touching GitHub. + +- [ ] **Step 3: Update the build line to include feedback** + +Find the existing build invocation around line 67: + +```sh +cargo build -p willow-relay -p willow-replay -p willow-storage 2>&1 | ... +``` + +Append `-p willow-feedback`. + +- [ ] **Step 4: Smoke test the dev stack** + +Run: `just dev-clean && just dev` +Expected: all four workers start (relay, replay, storage, feedback). Open `http://localhost:8080`, navigate to Settings → Help, click "Send Feedback", submit a test report. Result: the modal shows the failure-state copy for `Unconfigured` (since dev has no PAT). The fallback "file directly on GitHub" link works. + +`Ctrl+C` to stop. `just dev-clean` to reset. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/dev.sh +git commit -m "feat(dev): launch feedback worker, write peer ID to dev_assets" +``` + +### Task 5.6: Justfile additions + +**Files:** +- Modify: `justfile` + +- [ ] **Step 1: Add `test-feedback` target** + +Insert after the existing `test-relay` target (around line 43): + +```just +# Run feedback worker tests +test-feedback: + cargo test -p willow-feedback +``` + +- [ ] **Step 2: Append `-p willow-feedback` to `test-workers`** + +Edit the existing `test-workers` line (around line 47): + +```just +test-workers: + cargo test -p willow-worker -p willow-replay -p willow-storage -p willow-feedback -p willow-common +``` + +- [ ] **Step 3: Add `build-feedback` target** + +Insert near the existing `build-workers`: + +```just +# Build feedback worker binary (release) +build-feedback: + cargo build --release -p willow-feedback +``` + +And update `build-workers` to include feedback: + +```just +build-workers: + cargo build --release -p willow-replay -p willow-storage -p willow-feedback +``` + +- [ ] **Step 4: Append feedback to `docker-ids`** + +In the existing `docker-ids` target (around line 175): + +```just +docker-ids: + @docker compose exec replay-1 willow-replay --print-peer-id 2>/dev/null || echo "replay-1: not running" + @docker compose exec replay-2 willow-replay --print-peer-id 2>/dev/null || echo "replay-2: not running" + @docker compose exec storage-1 willow-storage --print-peer-id 2>/dev/null || echo "storage-1: not running" + @docker compose exec storage-2 willow-storage --print-peer-id 2>/dev/null || echo "storage-2: not running" + @docker compose exec feedback willow-feedback --print-peer-id 2>/dev/null || echo "feedback: not running" +``` + +- [ ] **Step 5: Verify each target works** + +Run: `just test-feedback` +Expected: pass. + +Run: `just test-workers` +Expected: pass (includes the new feedback tests). + +Run: `just build-feedback` +Expected: pass. + +- [ ] **Step 6: Commit** + +```bash +git add justfile +git commit -m "feat(justfile): test-feedback, build-feedback, docker-ids feedback" +``` + From 6b253adadd889ae1ac89bfd31ffc83f52fb7a66f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 05:56:20 +0000 Subject: [PATCH 20/27] docs(plan): Phase 6 + self-review (concrete test harness scaffolds) --- docs/plans/2026-04-27-feedback-system.md | 391 +++++++++++++++++++---- 1 file changed, 330 insertions(+), 61 deletions(-) diff --git a/docs/plans/2026-04-27-feedback-system.md b/docs/plans/2026-04-27-feedback-system.md index 62aeb1f1..c6f4213d 100644 --- a/docs/plans/2026-04-27-feedback-system.md +++ b/docs/plans/2026-04-27-feedback-system.md @@ -3403,36 +3403,86 @@ async fn submit_feedback_returns_worker_unreachable_when_no_listener() { // --- helpers --------------------------------------------------------------- +/// Mint a `ClientHandle` with a chosen `feedback_worker`. +/// Mirrors `test_client()` from `crates/client/src/lib.rs:969` but +/// adds `feedback_worker` to the produced client's config. async fn make_client( feedback_worker: Option, -) -> (ClientHandle, /* event loop join handle */ tokio::task::JoinHandle<()>) { - let cfg = ClientConfig { - feedback_worker, - persistence: false, - ..Default::default() - }; - let identity = willow_identity::Identity::generate(); - // The exact API for spawning a ClientHandle in tests is project-specific — - // the implementer follows the pattern in crates/client/src/tests/*.rs, - // typically `ClientHandle::::new_with(cfg, identity, network)` - // or similar. Returns (client, event_loop_handle). - todo!("follow existing test_client() helper from crates/client/src/tests/") +) -> (ClientHandle, willow_actor::Addr>) { + // `test_client()` builds a fully-configured ClientHandle on a + // standalone MemNetwork (no shared hub). Reuse it then patch the + // feedback_worker config on the returned handle. + let (mut client, broker) = willow_client::test_client(); + client.set_feedback_worker(feedback_worker); + (client, broker) } +/// Two-peer fixture: client on a `MemHub`, mock worker on the same +/// hub that responds to every `WorkerRequest::Feedback` by invoking +/// `handler`. Pattern adapted from `connected_pair()` in +/// `crates/client/src/tests/multi_peer_sync.rs` (around line 80). async fn spawn_client_and_mock_worker( handler: impl Fn(&WorkerRequest) -> WorkerResponse + Send + Sync + 'static, ) -> ( ClientHandle, - willow_identity::EndpointId, - tokio::task::JoinHandle<()>, - tokio::task::JoinHandle<()>, - std::sync::Arc, + willow_identity::EndpointId, // worker peer id + willow_actor::Addr>, // client broker + tokio::task::JoinHandle<()>, // mock worker task + std::sync::Arc, // shared hub ) { - todo!("reuse the multi-peer test harness from crates/client/src/tests/multi_peer_sync.rs") + use willow_common::{pack_wire, unpack_wire, WireMessage, WorkerWireMessage}; + use willow_network::mem::{MemHub, MemNetwork}; + use willow_network::{Network, TopicEvents}; + + let hub = std::sync::Arc::new(MemHub::new()); + + // Client on the hub. + let client_net = MemNetwork::new(&hub); + let (mut client, broker) = willow_client::test_client_on_hub(client_net.clone()).await; + // Pick a fixed peer ID for the mock worker. + let worker_identity = willow_identity::Identity::generate(); + let worker_peer = worker_identity.endpoint_id(); + client.set_feedback_worker(Some(worker_peer)); + + // Mock worker on the hub: subscribe to `_willow_workers`, decode + // each request, invoke `handler`, broadcast the response. + let worker_net = MemNetwork::new(&hub); + let topic = worker_net + .subscribe(willow_common::WORKERS_TOPIC.to_string()) + .await + .expect("subscribe"); + let handler = std::sync::Arc::new(handler); + let task = tokio::spawn(async move { + let mut events = topic.events(); + while let Some(ev) = events.next().await { + let willow_network::traits::TopicEvent::Received(msg) = ev else { continue }; + let Some((decoded, _signer)) = unpack_wire(&msg.content) else { continue }; + let WireMessage::Worker(WorkerWireMessage::Request { + request_id, + target_peer, + payload, + }) = decoded else { continue }; + if target_peer != worker_peer { continue } + let response = (handler)(&payload); + let reply = WorkerWireMessage::Response { + request_id, + target_peer: msg.sender, + payload: Box::new(response), + }; + let bytes = pack_wire(&WireMessage::Worker(reply), &worker_identity) + .expect("pack reply"); + topic.broadcast(bytes::Bytes::from(bytes)).await.ok(); + } + }); + + (client, worker_peer, broker, task, hub) } ``` -The two `todo!()` helpers MUST be filled in by following the existing client test harness. The plan can't write them here because the project's helper signatures are project-internal. **The implementer's first concrete action under Step 3 is to read `crates/client/src/tests/multi_peer_sync.rs` (or whichever sibling file currently contains the helpers) and copy-adapt the spawn pattern.** +Note this fixture depends on two small additions: + +1. `Client::set_feedback_worker(&mut self, peer: Option)` — small setter on `ClientHandle` that stores the peer ID for `submit_feedback` to consume. Add it next to the existing `Client` configuration setters. +2. `test_client_on_hub(net: MemNetwork)` — a sibling of `test_client()` at `crates/client/src/lib.rs:969` that takes a pre-built network instead of constructing one. If a similar helper already exists (`test_client_on_hub` at line 1235), reuse it; otherwise add it as a 5-line wrapper. - [ ] **Step 3: Wire the test module into the test list** @@ -3854,57 +3904,124 @@ git commit -m "feat(web): Help & Feedback settings tab + fallback link" This is the form. State machine is `Idle → Submitting → Success | Failure`. `dedup_id` is held in local component state and only regenerates on "Send another" / form-clear; "Retry" preserves it. -- [ ] **Step 1: Write the failing browser test** - -Create `crates/web/tests/feedback_modal.rs` (or extend `crates/web/tests/browser.rs` if that's the project pattern). The exact harness is project-specific; reuse the helper that mounts a component and queries the DOM. Each test's intent: - -```rust -//! Browser tests for the feedback modal. - -use wasm_bindgen_test::*; -wasm_bindgen_test_configure!(run_in_browser); - -#[wasm_bindgen_test] -fn modal_renders_form_fields() { - // Mount FeedbackModal with a mock submit handler. - // Assert: title input, category dropdown, body textarea, - // diagnostics checkbox, submit button, cancel button all present. - todo!("follow crates/web/tests/browser.rs harness") -} - -#[wasm_bindgen_test] -fn submit_disabled_when_title_empty() { - todo!() -} +- [ ] **Step 1: Write the failing browser tests** -#[wasm_bindgen_test] -fn other_category_reveals_detail_input() { - todo!() -} +The project's existing browser tests live in `crates/web/tests/browser.rs` (per CLAUDE.md). Reuse the existing `mount_test(...)` (or whatever the harness is named) helper that mounts a component into a DOM node and returns a handle for query/dispatch. Inspect that file first: -#[wasm_bindgen_test] -fn diagnostics_disclosure_renders_exact_value() { - todo!() -} +```bash +grep -n "fn mount_test\|fn tick\|wasm_bindgen_test" crates/web/tests/browser.rs | head -20 +``` -#[wasm_bindgen_test] -fn rate_limited_failure_shows_retry_after_minutes() { - // Mock client returns FeedbackError::RateLimited { retry_after_ms: 720_000 } - // Assert: failure inline text mentions "12 minutes". - todo!() -} +Add a new module `feedback_modal` inside `crates/web/tests/browser.rs` (or a new `crates/web/tests/feedback_modal.rs` if the project splits browser tests by topic — check for sibling files). Each test follows this shape: -#[wasm_bindgen_test] -fn send_another_clears_dedup_id() { - todo!() -} +```rust +mod feedback_modal { + use super::*; + use willow_common::FeedbackCategory; + use willow_web::components::feedback::FeedbackModal; + + /// Mount the modal with a stubbed submit handler that returns the + /// provided `expected` Result. Use this for state-machine tests. + fn mount_with_stub( + expected: Result, + ) -> impl /* whatever the harness's mount handle is */ { + // Replace `inject_submit_stub` with whatever context-injection + // mechanism the modal uses (likely a leptos::provide_context + // with a custom Submitter trait). + crate::mount_test(move || { + leptos::provide_context(StubSubmitter::new(expected.clone())); + view! { } + }) + } -#[wasm_bindgen_test] -fn retry_preserves_dedup_id() { - todo!() + #[wasm_bindgen_test] + fn modal_renders_form_fields() { + let h = mount_with_stub(Err(FeedbackError::Internal("unused".into()))); + assert!(h.query_selector("input[name='title']").is_some()); + assert!(h.query_selector("select[name='category']").is_some()); + assert!(h.query_selector("textarea[name='body']").is_some()); + assert!(h.query_selector("input[type='checkbox'][name='include_diagnostics']").is_some()); + assert!(h.query_selector("button.feedback-submit").is_some()); + assert!(h.query_selector("button.feedback-cancel").is_some()); + } + + #[wasm_bindgen_test] + fn submit_disabled_when_title_empty() { + let h = mount_with_stub(Err(FeedbackError::Internal("unused".into()))); + let submit = h.query_selector("button.feedback-submit").unwrap(); + assert!(submit.has_attribute("disabled")); + h.set_input_value("input[name='title']", "ok"); + h.tick().await; + assert!(!submit.has_attribute("disabled")); + } + + #[wasm_bindgen_test] + async fn other_category_reveals_detail_input() { + let h = mount_with_stub(Err(FeedbackError::Internal("unused".into()))); + assert!(h.query_selector("input[name='category_detail']").is_none()); + h.set_select_value("select[name='category']", "other"); + h.tick().await; + assert!(h.query_selector("input[name='category_detail']").is_some()); + } + + #[wasm_bindgen_test] + async fn diagnostics_disclosure_renders_exact_value() { + let h = mount_with_stub(Err(FeedbackError::Internal("unused".into()))); + h.click("details.diagnostics-disclosure summary"); + h.tick().await; + let txt = h.text("details.diagnostics-disclosure"); + assert!(txt.contains(env!("CARGO_PKG_VERSION"))); + // build_hash is option_env, so tolerate missing in dev. + } + + #[wasm_bindgen_test] + async fn rate_limited_failure_shows_retry_after_minutes() { + let h = mount_with_stub(Err(FeedbackError::RateLimited { retry_after_ms: 720_000 })); + h.set_input_value("input[name='title']", "t"); + h.set_input_value("textarea[name='body']", "b"); + h.click("button.feedback-submit"); + h.tick().await; + let err_text = h.text(".feedback-failure"); + assert!(err_text.contains("12 minutes"), "got: {err_text}"); + } + + #[wasm_bindgen_test] + async fn send_another_clears_dedup_id() { + let h = mount_with_stub(Ok("https://github.com/x/y/issues/1".parse().unwrap())); + // First submission. + h.set_input_value("input[name='title']", "t"); + h.set_input_value("textarea[name='body']", "b"); + h.click("button.feedback-submit"); + h.tick().await; + let dedup_first = h.read_test_data("dedup_id"); + // Click "Send another". + h.click(".feedback-send-another"); + h.tick().await; + let dedup_second = h.read_test_data("dedup_id"); + assert_ne!(dedup_first, dedup_second); + } + + #[wasm_bindgen_test] + async fn retry_preserves_dedup_id() { + let h = mount_with_stub(Err(FeedbackError::Timeout)); + h.set_input_value("input[name='title']", "t"); + h.set_input_value("textarea[name='body']", "b"); + h.click("button.feedback-submit"); + h.tick().await; + let dedup_first = h.read_test_data("dedup_id"); + h.click(".feedback-retry"); + h.tick().await; + let dedup_second = h.read_test_data("dedup_id"); + assert_eq!(dedup_first, dedup_second); + } } ``` +The implementer adapts: +1. The `StubSubmitter` glue — Leptos `provide_context` or however the modal accepts a substitute submit channel for tests. +2. The `h.read_test_data("dedup_id")` method — add a `data-test-dedup-id` attribute on the modal root when `cfg!(any(test, feature = "test-utils"))` is set so the test can assert on it. This is a dev-only DOM crumb; it disappears in release builds. +3. The selectors (`.feedback-failure`, `.feedback-submit`, etc.) — match whatever class names the modal actually renders. + - [ ] **Step 2: Run the browser tests, verify failure** Run: `wasm-pack test --headless --firefox crates/web --test feedback_modal` @@ -4404,4 +4521,156 @@ git add justfile git commit -m "feat(justfile): test-feedback, build-feedback, docker-ids feedback" ``` +--- + +## Phase 6: Final verification + handoff + +**Why now:** every component compiles and tests pass individually. We run the full quality gate one last time and validate the dev stack end-to-end. + +### Task 6.1: Full quality gate + +**Files:** none (verification only) + +- [ ] **Step 1: Run the project-wide check** + +Run: `just check` +Expected: fmt + clippy + tests + WASM all pass with zero warnings. This is the ship gate. + +If clippy flags anything, fix it inline before continuing — the project policy is zero warnings. + +- [ ] **Step 2: Run browser tests** + +Run: `just test-browser` +Expected: pass. + +- [ ] **Step 3: Run worker tests** + +Run: `just test-workers` +Expected: pass. + +- [ ] **Step 4: Run client tests** + +Run: `just test-client` +Expected: pass. + +- [ ] **Step 5: Inspect WASM bundle for placeholder leakage** + +Verify the production bundle still contains the `__INJECT_*__` tokens (so the entrypoint can substitute them): + +```bash +cd crates/web && trunk build --release && grep -c '__INJECT_FEEDBACK_PEER_ID__\|__INJECT_RELAY_URL__' dist/init.js +``` + +Expected: 4 (each placeholder appears twice in the file — once in the assignment and once in the equality check). + +- [ ] **Step 6: No commit (verification only)** + +If anything fails, fix it inline; don't proceed past this task with red CI. + +### Task 6.2: Live dev-stack smoke test + +**Files:** none (verification only) + +- [ ] **Step 1: Reset and start the dev stack** + +Run: `just dev-clean && just dev` +Expected: relay, replay, storage, feedback, web all start. + +- [ ] **Step 2: Verify the feedback peer ID is wired through** + +In a fresh terminal: + +```bash +cat crates/web/dev_assets/feedback-peer-id.txt +# Expect: a non-empty bech32 string starting with the project's +# bech32 prefix. + +curl -sSf http://localhost:8080/dev_assets/feedback-peer-id.txt +# Expect: same value (served by trunk). +``` + +- [ ] **Step 3: Exercise the UI** + +Open `http://localhost:8080` in a browser. Navigate to Settings → Help. Verify: + +1. The "Send Feedback" button is enabled (not the "not configured" state). +2. Clicking opens the modal. +3. Title, body, category, diagnostics checkbox all visible. +4. Diagnostics disclosure shows the exact `FeedbackDiagnostics` value. +5. The privacy notice below Submit is visible. +6. The fallback "file an issue on GitHub directly" link is present and points to `https://github.com/intendednull/willow/issues/new?...`. +7. Submitting a test report (`title="dev test"`, `body="hello"`) returns the `Unconfigured` failure state with a graceful inline error (the dev worker has no PAT — this is expected). +8. The fallback link works (opens a real GitHub issue draft when clicked). + +- [ ] **Step 4: Verify the worker logs the structured request line** + +In the dev terminal, the feedback worker output includes a line like: + +``` +[feedback] INFO feedback_request id= signer=… category=Bug body_len=5 dedup= github_status=unconfigured latency_ms= +``` + +The `github_status=unconfigured` confirms the dev path. The PAT, salt, title, and body must NOT appear in the log line. + +- [ ] **Step 5: Validate sanitization end-to-end** + +Submit a feedback report with the body containing adversarial markdown: + +``` +@everyone please look +![pixel](https://attacker/?ip=) + +``` + +The dev worker is in `Unconfigured` state so nothing is posted to GitHub, but the worker's request log still emits the structured line — the `body_len` should reflect the input bytes. (The actual sanitization assertion was covered by unit tests; this step just confirms the data path is intact.) + +- [ ] **Step 6: Stop the dev stack and clean up** + +`Ctrl+C` to stop services. `just dev-clean` to reset. + +- [ ] **Step 7: No commit (verification only)** + +### Task 6.3: Final spec-coverage review (no code; verification only) + +**Files:** none + +- [ ] **Step 1: Walk every spec section, confirm a task implemented it** + +Open `docs/specs/2026-04-27-feedback-system-design.md` side by side with this plan. For each numbered/bullet requirement, confirm: + +- §"New crate `willow-feedback`" → Task 2.1 (scaffold) + 2.2-2.9 (modules). +- §"Async trait change" → Task 1.3. +- §"Wire types" → Tasks 1.1, 1.2. +- §"Reporter peer ID via signer parameter" → Task 1.3 + 2.8 (role uses signer). +- §"Per-variant size cap" → Task 2.8 (`validate_request`). +- §"Client API" → Tasks 3.1, 3.2. +- §"Web UI" → Tasks 4.1, 4.2. +- §"Failure-state copy" → Task 4.2 (modal renders the table verbatim). +- §"Configuration mechanism" (`__WILLOW_FEEDBACK_PEER_ID`) → Tasks 5.1, 5.2. +- §"GitHub issue format" sanitization → Tasks 2.2 (sanitize), 2.8 (assemble). +- §"Reporter handle" → Task 2.3. +- §"Salt rotation runbook" → documented in spec; deployment runbook lives there, no implementation required beyond Task 2.5 + entrypoint at 5.3. +- §"Abuse protection" → Task 2.4 (rate limiter), 2.6 (throttle), 2.8 (cooldown + 401 transition + idempotency cache). +- §"Deployment" → Tasks 5.2-5.6. +- §"Observability" → Task 2.8 (one structured log per request) + Task 6.2 step 4 (verification). +- §"Forward compatibility" → trade-off documented in spec; behavioral verification not required (the bincode behavior is established). +- §"Testing" matrix: + - `willow-common` round-trips → Tasks 1.1, 1.2. + - `willow-feedback` role tests → Task 2.8. + - `willow-feedback` GitHub-client tests → Task 2.7. + - `willow-client` tests → Task 3.2. + - `willow-web` browser tests → Task 4.2. + +If any spec line has no corresponding task, **stop and add the missing task** — don't ship a half-implemented spec. + +- [ ] **Step 2: Push the final branch** + +```bash +git push -u origin claude/add-feedback-system-XP7YR +``` + +- [ ] **Step 3: (Optional) Open the PR** + +Only if the user explicitly asks. Per repo convention, do not auto-open PRs. + From f6fed27a1e0133e98e7aeee2f3b8d6986c031d71 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 06:06:57 +0000 Subject: [PATCH 21/27] feat(common): add FeedbackCategory, ClientPlatform, FeedbackDiagnostics wire types https://claude.ai/code/session_01F3RA1a1rcNxM83ZsQPnTZX --- crates/common/src/worker_types.rs | 93 +++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/crates/common/src/worker_types.rs b/crates/common/src/worker_types.rs index df1c83ff..a782e393 100644 --- a/crates/common/src/worker_types.rs +++ b/crates/common/src/worker_types.rs @@ -158,6 +158,51 @@ pub enum AllocationStrategy { Dynamic, } +/// Top-level category for a feedback report. Surfaced as a label and +/// title prefix on the GitHub issue. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[non_exhaustive] +pub enum FeedbackCategory { + Bug, + Suggestion, + /// Free-form category. `detail` is a short subcategory string the + /// user types (e.g. "performance", "docs"); shown in the issue + /// title prefix as `[Other:]`. + Other { + /// Optional, <= 60 chars. Validated by the worker. + detail: Option, + }, +} + +/// The submitting client's platform — coarse-grained on purpose so +/// the issue body cannot include a fingerprintable full UA string. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[non_exhaustive] +pub enum ClientPlatform { + /// Browser submission. `ua_family` is `"/"`, + /// e.g. `"firefox/138"`. <= 40 chars. + Web { ua_family: String }, + /// Native submission. `"linux"` / `"macos"` / `"windows"` and + /// e.g. `"x86_64"` / `"aarch64"`. + Native { os: String, arch: String }, +} + +/// Optional diagnostic info attached to a feedback report. Only +/// included when the user opts in via the UI checkbox; the disclosure +/// renders the *exact* value that will be sent. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[non_exhaustive] +pub struct FeedbackDiagnostics { + /// `CARGO_PKG_VERSION` of the submitting client. + pub app_version: String, + /// Short git SHA from `option_env!("WILLOW_BUILD_SHA")` injected + /// by `build.rs`. None in dev builds. + pub build_hash: Option, + /// IETF BCP 47 locale tag (e.g. `"en-US"`). + pub locale: Option, + pub client: ClientPlatform, +} + #[cfg(test)] mod tests { use super::*; @@ -504,4 +549,52 @@ mod tests { _ => panic!("expected Global"), } } + + #[test] + fn feedback_category_round_trips() { + for cat in [ + FeedbackCategory::Bug, + FeedbackCategory::Suggestion, + FeedbackCategory::Other { detail: None }, + FeedbackCategory::Other { + detail: Some("performance".to_string()), + }, + ] { + let bytes = bincode::serialize(&cat).unwrap(); + let decoded: FeedbackCategory = bincode::deserialize(&bytes).unwrap(); + assert_eq!(cat, decoded); + } + } + + #[test] + fn client_platform_round_trips() { + for cp in [ + ClientPlatform::Web { + ua_family: "firefox/138".to_string(), + }, + ClientPlatform::Native { + os: "linux".to_string(), + arch: "x86_64".to_string(), + }, + ] { + let bytes = bincode::serialize(&cp).unwrap(); + let decoded: ClientPlatform = bincode::deserialize(&bytes).unwrap(); + assert_eq!(cp, decoded); + } + } + + #[test] + fn feedback_diagnostics_round_trips() { + let diag = FeedbackDiagnostics { + app_version: "0.1.0".to_string(), + build_hash: Some("abc1234".to_string()), + locale: Some("en-US".to_string()), + client: ClientPlatform::Web { + ua_family: "firefox/138".to_string(), + }, + }; + let bytes = bincode::serialize(&diag).unwrap(); + let decoded: FeedbackDiagnostics = bincode::deserialize(&bytes).unwrap(); + assert_eq!(diag, decoded); + } } From 94bdc206b401b82fb8d0166122a03a01ec6beda8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 06:09:49 +0000 Subject: [PATCH 22/27] style(common): match existing derive order, doc all FeedbackDiagnostics fields --- crates/common/src/worker_types.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/common/src/worker_types.rs b/crates/common/src/worker_types.rs index a782e393..9690a9c0 100644 --- a/crates/common/src/worker_types.rs +++ b/crates/common/src/worker_types.rs @@ -160,7 +160,7 @@ pub enum AllocationStrategy { /// Top-level category for a feedback report. Surfaced as a label and /// title prefix on the GitHub issue. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[non_exhaustive] pub enum FeedbackCategory { Bug, @@ -176,7 +176,7 @@ pub enum FeedbackCategory { /// The submitting client's platform — coarse-grained on purpose so /// the issue body cannot include a fingerprintable full UA string. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[non_exhaustive] pub enum ClientPlatform { /// Browser submission. `ua_family` is `"/"`, @@ -190,7 +190,7 @@ pub enum ClientPlatform { /// Optional diagnostic info attached to a feedback report. Only /// included when the user opts in via the UI checkbox; the disclosure /// renders the *exact* value that will be sent. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[non_exhaustive] pub struct FeedbackDiagnostics { /// `CARGO_PKG_VERSION` of the submitting client. @@ -200,6 +200,7 @@ pub struct FeedbackDiagnostics { pub build_hash: Option, /// IETF BCP 47 locale tag (e.g. `"en-US"`). pub locale: Option, + /// Platform the submission originated from. pub client: ClientPlatform, } From 69a4a15b26a5b246f9bad9c3ebb79d0aee924dfc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 06:16:51 +0000 Subject: [PATCH 23/27] feat(common): add Feedback variants to worker wire types Extends WorkerRoleInfo, WorkerRequest, and WorkerResponse with Feedback variants; adds FeedbackErrReason enum; marks all three enums #[non_exhaustive]. Adds wildcard arms to downstream match sites in willow-replay, willow-storage, and willow-worker (test double + integration test) that were made non-exhaustive by the attribute. https://claude.ai/code/session_01F3RA1a1rcNxM83ZsQPnTZX --- crates/common/src/worker_types.rs | 180 ++++++++++++++++++++++++++++- crates/replay/src/role.rs | 3 + crates/storage/src/role.rs | 3 + crates/worker/src/actors/state.rs | 3 + crates/worker/tests/integration.rs | 3 + 5 files changed, 191 insertions(+), 1 deletion(-) diff --git a/crates/common/src/worker_types.rs b/crates/common/src/worker_types.rs index 9690a9c0..bfcb6deb 100644 --- a/crates/common/src/worker_types.rs +++ b/crates/common/src/worker_types.rs @@ -17,6 +17,7 @@ pub const SERVER_OPS_TOPIC: &str = "_willow_server_ops"; /// Combined role identity and capacity info. The variant determines /// the role — impossible to mismatch role type and capacity data. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[non_exhaustive] pub enum WorkerRoleInfo { Replay { servers_loaded: u32, @@ -32,6 +33,14 @@ pub enum WorkerRoleInfo { total_events_stored: u64, disk_used_bytes: u64, }, + Feedback { + reports_accepted: u64, + reports_rejected: u64, + /// Gauge: peers currently throttled by the per-peer bucket. + currently_rate_limited: u32, + /// Gauge: true if the worker is hot-tripped on the global cap. + global_rate_limited: bool, + }, // Future: File { ... }, Stream { ... }, Bot { ... } } @@ -41,6 +50,7 @@ impl WorkerRoleInfo { match self { WorkerRoleInfo::Replay { .. } => "replay", WorkerRoleInfo::Storage { .. } => "storage", + WorkerRoleInfo::Feedback { .. } => "feedback", } } } @@ -85,7 +95,8 @@ pub enum WorkerWireMessage { } /// Request payloads sent by clients to workers. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[non_exhaustive] pub enum WorkerRequest { /// State sync request (handled by replay nodes). /// Sends our heads so the worker can compute a delta. @@ -103,10 +114,26 @@ pub enum WorkerRequest { before: Option, limit: u32, }, + + /// Submit a user feedback report (handled by feedback nodes). + Feedback { + /// 16-byte client-generated dedup key. Worker maintains an LRU + /// cache of (signer, dedup_id) → issue_url so retries return + /// the original URL. + dedup_id: [u8; 16], + /// 1..=200 chars (worker-validated). + title: String, + category: FeedbackCategory, + /// 1..=8000 chars (worker-validated). Worker wraps this + /// verbatim in a fenced markdown code block on GitHub. + body: String, + diagnostics: Option, + }, } /// Response payloads sent by workers back to clients. #[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] pub enum WorkerResponse { /// Batch of events for sync catch-up. SyncBatch { events: Vec }, @@ -122,6 +149,37 @@ pub enum WorkerResponse { /// Request denied. Denied { reason: String }, + + /// Feedback report accepted; GitHub issue created or dedup hit. + FeedbackOk { issue_url: String }, + + /// Feedback report rejected. + FeedbackErr { reason: FeedbackErrReason }, +} + +impl PartialEq for WorkerResponse { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (WorkerResponse::Denied { reason: a }, WorkerResponse::Denied { reason: b }) => a == b, + ( + WorkerResponse::HistoryPage { + has_more: a_more, .. + }, + WorkerResponse::HistoryPage { + has_more: b_more, .. + }, + ) => a_more == b_more, + ( + WorkerResponse::FeedbackOk { issue_url: a }, + WorkerResponse::FeedbackOk { issue_url: b }, + ) => a == b, + ( + WorkerResponse::FeedbackErr { reason: a }, + WorkerResponse::FeedbackErr { reason: b }, + ) => a == b, + _ => false, + } + } } /// The trait that each worker binary implements. @@ -204,6 +262,31 @@ pub struct FeedbackDiagnostics { pub client: ClientPlatform, } +/// Reason a feedback request was rejected. Units are MILLISECONDS to +/// align with the broader `WireRejectReason` design +/// ([`docs/specs/2026-04-24-error-prefixes.md`]); consolidating the +/// two enums is a follow-up. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[non_exhaustive] +pub enum FeedbackErrReason { + RateLimited { + retry_after_ms: u64, + }, + /// `field` <= 64 chars; `message` <= 200 chars (worker-enforced + /// before constructing the reply, client-enforced on receipt). + InvalidInput { + field: String, + message: String, + }, + GithubFailure { + status: u16, + /// GitHub's `message` field, truncated to 200 chars. + message: Option, + }, + /// Worker has no PAT configured, or PAT was revoked (401). + Unconfigured, +} + #[cfg(test)] mod tests { use super::*; @@ -598,4 +681,99 @@ mod tests { let decoded: FeedbackDiagnostics = bincode::deserialize(&bytes).unwrap(); assert_eq!(diag, decoded); } + + #[test] + fn feedback_err_reason_variants_round_trip() { + for r in [ + FeedbackErrReason::RateLimited { + retry_after_ms: 12_345, + }, + FeedbackErrReason::InvalidInput { + field: "title".to_string(), + message: "too long".to_string(), + }, + FeedbackErrReason::GithubFailure { + status: 422, + message: Some("Validation Failed".to_string()), + }, + FeedbackErrReason::GithubFailure { + status: 0, + message: None, + }, + FeedbackErrReason::Unconfigured, + ] { + let bytes = bincode::serialize(&r).unwrap(); + let decoded: FeedbackErrReason = bincode::deserialize(&bytes).unwrap(); + assert_eq!(r, decoded); + } + } + + #[test] + fn worker_request_feedback_round_trip() { + let id = Identity::generate(); + let req = WorkerRequest::Feedback { + dedup_id: [7u8; 16], + title: "It crashes".to_string(), + category: FeedbackCategory::Bug, + body: "Steps:\n1. open the app\n2. it crashes".to_string(), + diagnostics: Some(FeedbackDiagnostics { + app_version: "0.1.0".to_string(), + build_hash: Some("abc1234".to_string()), + locale: Some("en-US".to_string()), + client: ClientPlatform::Web { + ua_family: "firefox/138".to_string(), + }, + }), + }; + let msg = WorkerWireMessage::Request { + request_id: "rid-1".to_string(), + target_peer: id.endpoint_id(), + payload: req.clone(), + }; + let decoded = worker_wire_round_trip(msg, &id); + match decoded { + WorkerWireMessage::Request { payload, .. } => assert_eq!(payload, req), + _ => panic!("expected Request"), + } + } + + #[test] + fn worker_response_feedback_round_trip() { + let id = Identity::generate(); + for resp in [ + WorkerResponse::FeedbackOk { + issue_url: "https://github.com/x/y/issues/42".to_string(), + }, + WorkerResponse::FeedbackErr { + reason: FeedbackErrReason::RateLimited { + retry_after_ms: 60_000, + }, + }, + ] { + let msg = WorkerWireMessage::Response { + request_id: "rid-1".to_string(), + target_peer: id.endpoint_id(), + payload: Box::new(resp.clone()), + }; + let decoded = worker_wire_round_trip(msg, &id); + match decoded { + WorkerWireMessage::Response { payload, .. } => assert_eq!(*payload, resp), + _ => panic!("expected Response"), + } + } + } + + #[test] + fn worker_role_info_feedback_round_trip_and_name() { + let info = WorkerRoleInfo::Feedback { + reports_accepted: 17, + reports_rejected: 4, + currently_rate_limited: 2, + global_rate_limited: false, + }; + let bytes = bincode::serialize(&info).unwrap(); + let decoded: WorkerRoleInfo = bincode::deserialize(&bytes).unwrap(); + assert_eq!(info, decoded); + assert_eq!(info.role_name(), "feedback"); + } } diff --git a/crates/replay/src/role.rs b/crates/replay/src/role.rs index 628f7f3d..8d3a13dc 100644 --- a/crates/replay/src/role.rs +++ b/crates/replay/src/role.rs @@ -317,6 +317,9 @@ impl WorkerRole for ReplayRole { WorkerRequest::History { .. } => WorkerResponse::Denied { reason: "replay nodes do not serve history".to_string(), }, + _ => WorkerResponse::Denied { + reason: "unsupported request type".to_string(), + }, } } diff --git a/crates/storage/src/role.rs b/crates/storage/src/role.rs index e58b0e44..345c9401 100644 --- a/crates/storage/src/role.rs +++ b/crates/storage/src/role.rs @@ -83,6 +83,9 @@ impl WorkerRole for StorageRole { reason: format!("sync query failed: {e}"), }, }, + _ => WorkerResponse::Denied { + reason: "unsupported request type".to_string(), + }, } } } diff --git a/crates/worker/src/actors/state.rs b/crates/worker/src/actors/state.rs index 6a8fdd58..8afa3f5a 100644 --- a/crates/worker/src/actors/state.rs +++ b/crates/worker/src/actors/state.rs @@ -130,6 +130,9 @@ mod tests { WorkerRequest::History { .. } => WorkerResponse::Denied { reason: "not a storage node".to_string(), }, + _ => WorkerResponse::Denied { + reason: "unsupported request type".to_string(), + }, } } } diff --git a/crates/worker/tests/integration.rs b/crates/worker/tests/integration.rs index 60d62a95..c59b83c1 100644 --- a/crates/worker/tests/integration.rs +++ b/crates/worker/tests/integration.rs @@ -72,6 +72,9 @@ impl WorkerRole for TestReplayRole { WorkerRequest::History { .. } => WorkerResponse::Denied { reason: "not a storage node".to_string(), }, + _ => WorkerResponse::Denied { + reason: "unsupported request type".to_string(), + }, } } } From 7ffd7e8bc233cd7603ab8ef62a3472a120668dab Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 06:29:04 +0000 Subject: [PATCH 24/27] fix(common): cover all WorkerResponse variants in PartialEq impl SyncBatch and Snapshot previously fell through to `_ => false`, making any `assert_eq!` self-comparison silently return false. Adds explicit arms for both: SyncBatch compares by event content hashes (the canonical identity of an Event); Snapshot compares by its documented canonical SHA-256 hash plus post-snapshot event hashes. Adds regression tests for both variants. https://claude.ai/code/session_01F3RA1a1rcNxM83ZsQPnTZX --- crates/common/src/worker_types.rs | 49 +++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/crates/common/src/worker_types.rs b/crates/common/src/worker_types.rs index bfcb6deb..a0d3561e 100644 --- a/crates/common/src/worker_types.rs +++ b/crates/common/src/worker_types.rs @@ -177,6 +177,29 @@ impl PartialEq for WorkerResponse { WorkerResponse::FeedbackErr { reason: a }, WorkerResponse::FeedbackErr { reason: b }, ) => a == b, + (WorkerResponse::SyncBatch { events: a }, WorkerResponse::SyncBatch { events: b }) => { + // `Event` does not derive `PartialEq`; compare by the + // canonical content hash, which IS each event's identity. + a.len() == b.len() + && a.iter().zip(b.iter()).all(|(x, y)| x.hash == y.hash) + } + ( + WorkerResponse::Snapshot { + snapshot: a, + post_snapshot_events: ae, + }, + WorkerResponse::Snapshot { + snapshot: b, + post_snapshot_events: be, + }, + ) => { + // Compare snapshot by its canonical SHA-256 hash (documented + // as the identity of the snapshot) and post-snapshot events + // by their content hashes. + a.hash == b.hash + && ae.len() == be.len() + && ae.iter().zip(be.iter()).all(|(x, y)| x.hash == y.hash) + } _ => false, } } @@ -763,6 +786,32 @@ mod tests { } } + #[test] + fn worker_response_sync_batch_self_equal() { + // Previously SyncBatch fell through to `_ => false`, making + // `assert_eq!(resp, resp.clone())` silently fail. + let resp = WorkerResponse::SyncBatch { events: Vec::new() }; + assert_eq!(resp, resp.clone()); + } + + #[test] + fn worker_response_snapshot_self_equal() { + use willow_state::{HeadsSummary, ServerState, Snapshot}; + + // Previously Snapshot fell through to `_ => false`; ensure the + // new arm matches and compares by canonical hash. + let id = Identity::generate(); + let state = ServerState::new("srv-eq", "Eq Server", id.endpoint_id()); + let heads = HeadsSummary::default(); + let snapshot = Snapshot::new(state, heads); + + let resp = WorkerResponse::Snapshot { + snapshot: Box::new(snapshot), + post_snapshot_events: Vec::new(), + }; + assert_eq!(resp, resp.clone()); + } + #[test] fn worker_role_info_feedback_round_trip_and_name() { let info = WorkerRoleInfo::Feedback { From 932036ed7a132a97f55cffab359f72f608c4f835 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 06:43:07 +0000 Subject: [PATCH 25/27] refactor(worker): make WorkerRole::handle_request async + accept signer --- Cargo.lock | 3 + crates/common/Cargo.toml | 1 + crates/common/src/worker_types.rs | 14 ++- crates/replay/Cargo.toml | 1 + crates/replay/src/role.rs | 127 ++++++++++++++++++-------- crates/storage/Cargo.toml | 1 + crates/storage/src/role.rs | 89 ++++++++++++------ crates/worker/Cargo.toml | 1 + crates/worker/src/actors/heartbeat.rs | 4 +- crates/worker/src/actors/mod.rs | 5 +- crates/worker/src/actors/network.rs | 8 +- crates/worker/src/actors/state.rs | 54 +++++++---- crates/worker/src/actors/sync.rs | 4 +- crates/worker/tests/integration.rs | 76 ++++++++++----- 14 files changed, 270 insertions(+), 118 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7273ddc7..7a539de8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6099,6 +6099,7 @@ dependencies = [ name = "willow-common" version = "0.1.0" dependencies = [ + "async-trait", "bincode", "serde", "tracing", @@ -6207,6 +6208,7 @@ name = "willow-replay" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", "clap", "tokio", "tracing", @@ -6235,6 +6237,7 @@ name = "willow-storage" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", "bincode", "clap", "rusqlite", diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 54dbfb33..d8b504e2 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -10,3 +10,4 @@ willow-transport = { path = "../transport" } serde = { workspace = true } bincode = { workspace = true } tracing = { workspace = true } +async-trait = { workspace = true } diff --git a/crates/common/src/worker_types.rs b/crates/common/src/worker_types.rs index a0d3561e..46c849ca 100644 --- a/crates/common/src/worker_types.rs +++ b/crates/common/src/worker_types.rs @@ -180,8 +180,7 @@ impl PartialEq for WorkerResponse { (WorkerResponse::SyncBatch { events: a }, WorkerResponse::SyncBatch { events: b }) => { // `Event` does not derive `PartialEq`; compare by the // canonical content hash, which IS each event's identity. - a.len() == b.len() - && a.iter().zip(b.iter()).all(|(x, y)| x.hash == y.hash) + a.len() == b.len() && a.iter().zip(b.iter()).all(|(x, y)| x.hash == y.hash) } ( WorkerResponse::Snapshot { @@ -209,6 +208,7 @@ impl PartialEq for WorkerResponse { /// /// The state actor owns the implementor exclusively — `&mut self` is /// safe because no other task can access it concurrently. +#[async_trait::async_trait] pub trait WorkerRole: Send + 'static { /// Returns combined role identity and capacity info for heartbeats. fn role_info(&self) -> WorkerRoleInfo; @@ -216,8 +216,14 @@ pub trait WorkerRole: Send + 'static { /// Called when an event is received from gossipsub. fn on_event(&mut self, event: &Event); - /// Handle an incoming request from a client peer. - fn handle_request(&mut self, req: WorkerRequest) -> WorkerResponse; + /// Handle an inbound request from a client. `signer` is the + /// verified Ed25519 signer of the inbound `WireMessage`; roles + /// that don't need it (replay, storage) ignore the parameter. + async fn handle_request( + &mut self, + signer: willow_identity::EndpointId, + req: WorkerRequest, + ) -> WorkerResponse; /// Returns heads summaries for all tracked servers. /// Used by the sync actor to broadcast current DAG state. diff --git a/crates/replay/Cargo.toml b/crates/replay/Cargo.toml index 85e54421..2a243c72 100644 --- a/crates/replay/Cargo.toml +++ b/crates/replay/Cargo.toml @@ -17,6 +17,7 @@ anyhow = { workspace = true } tracing = { workspace = true } tracing-subscriber = { version = "0.3", features = ["env-filter"] } tokio = { workspace = true, features = ["full"] } +async-trait = { workspace = true } [dev-dependencies] uuid = { workspace = true } diff --git a/crates/replay/src/role.rs b/crates/replay/src/role.rs index 8d3a13dc..3fcc0ef7 100644 --- a/crates/replay/src/role.rs +++ b/crates/replay/src/role.rs @@ -220,6 +220,7 @@ impl ReplayRole { } } +#[async_trait::async_trait] impl WorkerRole for ReplayRole { fn role_info(&self) -> WorkerRoleInfo { WorkerRoleInfo::Replay { @@ -261,7 +262,11 @@ impl WorkerRole for ReplayRole { self.ingest_event(&server_id, event); } - fn handle_request(&mut self, req: WorkerRequest) -> WorkerResponse { + async fn handle_request( + &mut self, + _signer: willow_identity::EndpointId, + req: WorkerRequest, + ) -> WorkerResponse { match req { WorkerRequest::Sync { server_id, heads } => { let data = match self.servers.get(&server_id) { @@ -334,6 +339,21 @@ mod tests { use willow_identity::Identity; use willow_state::{EventHash, EventKind}; + /// Drive an async `handle_request` call from sync `#[test]` bodies. + fn block_on(f: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(f) + } + + /// Test signer for `handle_request` calls. The replay role ignores the + /// signer (it only uses it for permission-aware roles like feedback), + /// so any deterministic value works. + fn test_signer() -> willow_identity::EndpointId { + Identity::generate().endpoint_id() + } + fn make_dag_event(id: &Identity, seq: u64, prev: EventHash, kind: EventKind) -> Event { Event::new(id, seq, prev, vec![], kind, seq * 1000) } @@ -425,10 +445,13 @@ mod tests { } // Empty heads = new peer, should get all events. - let resp = role.handle_request(WorkerRequest::Sync { - server_id: "srv-1".to_string(), - heads: HeadsSummary::default(), - }); + let resp = block_on(role.handle_request( + test_signer(), + WorkerRequest::Sync { + server_id: "srv-1".to_string(), + heads: HeadsSummary::default(), + }, + )); match resp { WorkerResponse::SyncBatch { events } => assert_eq!(events.len(), 4), @@ -440,10 +463,13 @@ mod tests { fn sync_request_unknown_server_denied() { let mut role = ReplayRole::new(ReplayConfig::default()); - let resp = role.handle_request(WorkerRequest::Sync { - server_id: "nonexistent".to_string(), - heads: HeadsSummary::default(), - }); + let resp = block_on(role.handle_request( + test_signer(), + WorkerRequest::Sync { + server_id: "nonexistent".to_string(), + heads: HeadsSummary::default(), + }, + )); match resp { WorkerResponse::Denied { reason } => assert!(reason.contains("unknown server")), @@ -475,10 +501,13 @@ mod tests { hash: EventHash::from_bytes(b"doesnt-matter-for-delta"), }, ); - let resp = role.handle_request(WorkerRequest::Sync { - server_id: "srv-1".to_string(), - heads: HeadsSummary { heads: their_heads }, - }); + let resp = block_on(role.handle_request( + test_signer(), + WorkerRequest::Sync { + server_id: "srv-1".to_string(), + heads: HeadsSummary { heads: their_heads }, + }, + )); match resp { WorkerResponse::SyncBatch { events } => assert_eq!(events.len(), 2), @@ -490,12 +519,15 @@ mod tests { fn history_request_denied_by_replay_node() { let mut role = ReplayRole::new(ReplayConfig::default()); - let resp = role.handle_request(WorkerRequest::History { - server_id: "srv-1".to_string(), - channel: Some("general".to_string()), - before: None, - limit: 50, - }); + let resp = block_on(role.handle_request( + test_signer(), + WorkerRequest::History { + server_id: "srv-1".to_string(), + channel: Some("general".to_string()), + before: None, + limit: 50, + }, + )); match resp { WorkerResponse::Denied { reason } => assert!(reason.contains("history")), @@ -689,10 +721,13 @@ mod tests { }, ); - let resp = role.handle_request(WorkerRequest::Sync { - server_id: "srv-1".to_string(), - heads: HeadsSummary { heads: their_heads }, - }); + let resp = block_on(role.handle_request( + test_signer(), + WorkerRequest::Sync { + server_id: "srv-1".to_string(), + heads: HeadsSummary { heads: their_heads }, + }, + )); match resp { WorkerResponse::SyncBatch { events } => assert!( @@ -721,10 +756,13 @@ mod tests { // Brand-new peer with no heads at all → delta condition fires // (heads.heads.is_empty() skips the they_are_behind check) and // events_since returns all 5 events. - let resp = role.handle_request(WorkerRequest::Sync { - server_id: "srv-1".to_string(), - heads: HeadsSummary::default(), - }); + let resp = block_on(role.handle_request( + test_signer(), + WorkerRequest::Sync { + server_id: "srv-1".to_string(), + heads: HeadsSummary::default(), + }, + )); match resp { WorkerResponse::SyncBatch { events } => assert_eq!( @@ -811,12 +849,15 @@ mod tests { }, ); - let resp = role.handle_request(WorkerRequest::Sync { - server_id: "srv-1".to_string(), - heads: HeadsSummary { - heads: their_heads.clone(), + let resp = block_on(role.handle_request( + test_signer(), + WorkerRequest::Sync { + server_id: "srv-1".to_string(), + heads: HeadsSummary { + heads: their_heads.clone(), + }, }, - }); + )); // Pre-compaction: member's events are still in memory so we get a // SyncBatch with them rather than a Snapshot. @@ -905,10 +946,13 @@ mod tests { }, ); - let resp = role.handle_request(WorkerRequest::Sync { - server_id: "srv-1".to_string(), - heads: HeadsSummary { heads: their_heads }, - }); + let resp = block_on(role.handle_request( + test_signer(), + WorkerRequest::Sync { + server_id: "srv-1".to_string(), + heads: HeadsSummary { heads: their_heads }, + }, + )); match resp { WorkerResponse::SyncBatch { events } => assert_eq!(events.len(), 2), @@ -942,10 +986,13 @@ mod tests { }, ); - let resp = role.handle_request(WorkerRequest::Sync { - server_id: "srv-1".to_string(), - heads: HeadsSummary { heads: their_heads }, - }); + let resp = block_on(role.handle_request( + test_signer(), + WorkerRequest::Sync { + server_id: "srv-1".to_string(), + heads: HeadsSummary { heads: their_heads }, + }, + )); match resp { WorkerResponse::SyncBatch { events } => { diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index a6afc977..65f16082 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -21,6 +21,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } tokio = { workspace = true, features = ["full"] } bincode = { workspace = true } serde = { workspace = true } +async-trait = { workspace = true } [dev-dependencies] tempfile = "3" diff --git a/crates/storage/src/role.rs b/crates/storage/src/role.rs index 345c9401..1eb43140 100644 --- a/crates/storage/src/role.rs +++ b/crates/storage/src/role.rs @@ -32,6 +32,7 @@ impl StorageRole { } } +#[async_trait::async_trait] impl WorkerRole for StorageRole { fn role_info(&self) -> WorkerRoleInfo { let total = self.store.count().unwrap_or_else(|e| { @@ -59,7 +60,11 @@ impl WorkerRole for StorageRole { } } - fn handle_request(&mut self, req: WorkerRequest) -> WorkerResponse { + async fn handle_request( + &mut self, + _signer: willow_identity::EndpointId, + req: WorkerRequest, + ) -> WorkerResponse { match req { WorkerRequest::History { server_id, @@ -97,6 +102,21 @@ mod tests { use willow_identity::Identity; use willow_state::{EventHash, EventKind, HeadsSummary}; + /// Drive an async `handle_request` call from sync `#[test]` bodies. + fn block_on(f: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(f) + } + + /// Test signer for `handle_request` calls. The storage role ignores the + /// signer (it only uses it for permission-aware roles like feedback), + /// so any deterministic value works. + fn test_signer() -> willow_identity::EndpointId { + Identity::generate().endpoint_id() + } + fn make_message(id: &Identity, seq: u64, prev: EventHash, channel: &str) -> Event { Event::new( id, @@ -138,12 +158,15 @@ mod tests { role.on_event(&e); } - let resp = role.handle_request(WorkerRequest::History { - server_id: "srv-1".to_string(), - channel: Some("general".to_string()), - before: None, - limit: 3, - }); + let resp = block_on(role.handle_request( + test_signer(), + WorkerRequest::History { + server_id: "srv-1".to_string(), + channel: Some("general".to_string()), + before: None, + limit: 3, + }, + )); match resp { WorkerResponse::HistoryPage { events, has_more } => { @@ -169,10 +192,13 @@ mod tests { } // Empty heads = new peer, should get all events. - let resp = role.handle_request(WorkerRequest::Sync { - server_id: "srv-1".to_string(), - heads: HeadsSummary::default(), - }); + let resp = block_on(role.handle_request( + test_signer(), + WorkerRequest::Sync { + server_id: "srv-1".to_string(), + heads: HeadsSummary::default(), + }, + )); match resp { WorkerResponse::SyncBatch { events } => assert_eq!(events.len(), 3), @@ -205,10 +231,13 @@ mod tests { hash: hashes[2].1, }, ); - let resp = role.handle_request(WorkerRequest::Sync { - server_id: "srv-1".to_string(), - heads: HeadsSummary { heads: their_heads }, - }); + let resp = block_on(role.handle_request( + test_signer(), + WorkerRequest::Sync { + server_id: "srv-1".to_string(), + heads: HeadsSummary { heads: their_heads }, + }, + )); match resp { WorkerResponse::SyncBatch { events } => assert_eq!(events.len(), 2), @@ -269,12 +298,15 @@ mod tests { let e = make_message(&id, 1, EventHash::ZERO, "general"); role.on_event(&e); - let resp = role.handle_request(WorkerRequest::History { - server_id: "nonexistent".to_string(), - channel: Some("general".to_string()), - before: None, - limit: 10, - }); + let resp = block_on(role.handle_request( + test_signer(), + WorkerRequest::History { + server_id: "nonexistent".to_string(), + channel: Some("general".to_string()), + before: None, + limit: 10, + }, + )); match resp { WorkerResponse::HistoryPage { events, has_more } => { @@ -295,12 +327,15 @@ mod tests { let e = make_message(&id, 1, EventHash::ZERO, "general"); role.on_event(&e); - let resp = role.handle_request(WorkerRequest::History { - server_id: "srv-1".to_string(), - channel: Some("nonexistent".to_string()), - before: None, - limit: 10, - }); + let resp = block_on(role.handle_request( + test_signer(), + WorkerRequest::History { + server_id: "srv-1".to_string(), + channel: Some("nonexistent".to_string()), + before: None, + limit: 10, + }, + )); match resp { WorkerResponse::HistoryPage { events, has_more } => { diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index d3411775..d5d5e5f0 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -18,6 +18,7 @@ tracing = { workspace = true } clap = { version = "4", features = ["derive"] } anyhow = { workspace = true } uuid = { workspace = true } +async-trait = { workspace = true } [features] test-utils = [] diff --git a/crates/worker/src/actors/heartbeat.rs b/crates/worker/src/actors/heartbeat.rs index 6aa7c9e4..6074226e 100644 --- a/crates/worker/src/actors/heartbeat.rs +++ b/crates/worker/src/actors/heartbeat.rs @@ -121,6 +121,7 @@ mod tests { /// A minimal test role for the state actor. struct TestRole; + #[async_trait::async_trait] impl crate::WorkerRole for TestRole { fn role_info(&self) -> WorkerRoleInfo { WorkerRoleInfo::Replay { @@ -131,8 +132,9 @@ mod tests { } } fn on_event(&mut self, _event: &willow_state::Event) {} - fn handle_request( + async fn handle_request( &mut self, + _signer: willow_identity::EndpointId, _req: crate::types::WorkerRequest, ) -> crate::types::WorkerResponse { crate::types::WorkerResponse::Denied { diff --git a/crates/worker/src/actors/mod.rs b/crates/worker/src/actors/mod.rs index 24073969..864260b3 100644 --- a/crates/worker/src/actors/mod.rs +++ b/crates/worker/src/actors/mod.rs @@ -23,7 +23,10 @@ impl Message for EventMsg { } /// A client request that needs a response. -pub struct WorkerRequestMsg(pub WorkerRequest); +pub struct WorkerRequestMsg { + pub req: WorkerRequest, + pub signer: willow_identity::EndpointId, +} impl Message for WorkerRequestMsg { type Result = WorkerResponse; } diff --git a/crates/worker/src/actors/network.rs b/crates/worker/src/actors/network.rs index 2ded203f..7b34ce24 100644 --- a/crates/worker/src/actors/network.rs +++ b/crates/worker/src/actors/network.rs @@ -135,7 +135,13 @@ impl Handler request_id, payload, } => { - if let Ok(response) = state_addr.ask(WorkerRequestMsg(payload)).await { + if let Ok(response) = state_addr + .ask(WorkerRequestMsg { + req: payload, + signer: requester, + }) + .await + { // target_peer identifies the original requester so // clients can filter responses addressed to them. let reply = WorkerWireMessage::Response { diff --git a/crates/worker/src/actors/state.rs b/crates/worker/src/actors/state.rs index 8afa3f5a..3392cb4c 100644 --- a/crates/worker/src/actors/state.rs +++ b/crates/worker/src/actors/state.rs @@ -50,13 +50,12 @@ impl Handler for StateActor { } impl Handler for StateActor { - fn handle( + async fn handle( &mut self, msg: WorkerRequestMsg, _ctx: &mut Context, - ) -> impl std::future::Future + Send { - let response = self.role.handle_request(msg.0); - async move { response } + ) -> crate::types::WorkerResponse { + self.role.handle_request(msg.signer, msg.req).await } } @@ -110,6 +109,7 @@ mod tests { } } + #[async_trait::async_trait] impl WorkerRole for TestRole { fn role_info(&self) -> WorkerRoleInfo { WorkerRoleInfo::Replay { @@ -124,7 +124,11 @@ mod tests { self.event_count += 1; } - fn handle_request(&mut self, req: WorkerRequest) -> WorkerResponse { + async fn handle_request( + &mut self, + _signer: willow_identity::EndpointId, + req: WorkerRequest, + ) -> WorkerResponse { match req { WorkerRequest::Sync { .. } => WorkerResponse::SyncBatch { events: vec![] }, WorkerRequest::History { .. } => WorkerResponse::Denied { @@ -184,12 +188,17 @@ mod tests { ready: None, }); + let signer = willow_identity::Identity::generate().endpoint_id(); + // Sync request. let resp = addr - .ask(WorkerRequestMsg(WorkerRequest::Sync { - server_id: "srv".to_string(), - heads: HeadsSummary::default(), - })) + .ask(WorkerRequestMsg { + req: WorkerRequest::Sync { + server_id: "srv".to_string(), + heads: HeadsSummary::default(), + }, + signer, + }) .await .unwrap(); match resp { @@ -199,12 +208,15 @@ mod tests { // History request (denied by replay role). let resp = addr - .ask(WorkerRequestMsg(WorkerRequest::History { - server_id: "srv".to_string(), - channel: Some("general".to_string()), - before: None, - limit: 50, - })) + .ask(WorkerRequestMsg { + req: WorkerRequest::History { + server_id: "srv".to_string(), + channel: Some("general".to_string()), + before: None, + limit: 50, + }, + signer, + }) .await .unwrap(); match resp { @@ -242,12 +254,16 @@ mod tests { ready: None, }); + let signer = willow_identity::Identity::generate().endpoint_id(); let mut futs = vec![]; for _ in 0..10 { - let f = addr.ask(WorkerRequestMsg(WorkerRequest::Sync { - server_id: "srv".to_string(), - heads: HeadsSummary::default(), - })); + let f = addr.ask(WorkerRequestMsg { + req: WorkerRequest::Sync { + server_id: "srv".to_string(), + heads: HeadsSummary::default(), + }, + signer, + }); futs.push(f); } diff --git a/crates/worker/src/actors/sync.rs b/crates/worker/src/actors/sync.rs index abb74a41..ad9cff8c 100644 --- a/crates/worker/src/actors/sync.rs +++ b/crates/worker/src/actors/sync.rs @@ -105,6 +105,7 @@ mod tests { /// A test role that provides known heads summaries. struct TestSyncRole; + #[async_trait::async_trait] impl crate::WorkerRole for TestSyncRole { fn role_info(&self) -> crate::types::WorkerRoleInfo { crate::types::WorkerRoleInfo::Replay { @@ -115,8 +116,9 @@ mod tests { } } fn on_event(&mut self, _event: &willow_state::Event) {} - fn handle_request( + async fn handle_request( &mut self, + _signer: willow_identity::EndpointId, _req: crate::types::WorkerRequest, ) -> crate::types::WorkerResponse { crate::types::WorkerResponse::Denied { diff --git a/crates/worker/tests/integration.rs b/crates/worker/tests/integration.rs index c59b83c1..c708add3 100644 --- a/crates/worker/tests/integration.rs +++ b/crates/worker/tests/integration.rs @@ -34,6 +34,7 @@ impl TestReplayRole { } } +#[async_trait::async_trait] impl WorkerRole for TestReplayRole { fn role_info(&self) -> WorkerRoleInfo { WorkerRoleInfo::Replay { @@ -54,7 +55,11 @@ impl WorkerRole for TestReplayRole { } } - fn handle_request(&mut self, req: WorkerRequest) -> WorkerResponse { + async fn handle_request( + &mut self, + _signer: willow_identity::EndpointId, + req: WorkerRequest, + ) -> WorkerResponse { match req { WorkerRequest::Sync { heads, .. } => { if heads.heads.is_empty() { @@ -132,12 +137,17 @@ async fn state_actor_with_replay_role_full_flow() { _ => panic!("expected Replay"), } + let signer = Identity::generate().endpoint_id(); + // 3. Sync request with empty heads — should return all events. let resp = addr - .ask(WorkerRequestMsg(WorkerRequest::Sync { - server_id: "srv-1".to_string(), - heads: HeadsSummary::default(), - })) + .ask(WorkerRequestMsg { + req: WorkerRequest::Sync { + server_id: "srv-1".to_string(), + heads: HeadsSummary::default(), + }, + signer, + }) .await .unwrap(); match resp { @@ -147,12 +157,15 @@ async fn state_actor_with_replay_role_full_flow() { // 4. History request — should be denied. let resp = addr - .ask(WorkerRequestMsg(WorkerRequest::History { - server_id: "srv-1".to_string(), - channel: Some("general".to_string()), - before: None, - limit: 10, - })) + .ask(WorkerRequestMsg { + req: WorkerRequest::History { + server_id: "srv-1".to_string(), + channel: Some("general".to_string()), + before: None, + limit: 10, + }, + signer, + }) .await .unwrap(); match resp { @@ -231,13 +244,18 @@ async fn concurrent_requests_all_resolve() { ready: None, }); + let signer = Identity::generate().endpoint_id(); + // Fire 50 concurrent requests. let mut futs = vec![]; for _ in 0..50 { - let f = addr.ask(WorkerRequestMsg(WorkerRequest::Sync { - server_id: "srv-1".to_string(), - heads: HeadsSummary::default(), - })); + let f = addr.ask(WorkerRequestMsg { + req: WorkerRequest::Sync { + server_id: "srv-1".to_string(), + heads: HeadsSummary::default(), + }, + signer, + }); futs.push(f); } @@ -282,12 +300,17 @@ async fn events_applied_then_queried_via_request() { addr.do_send(EventMsg(e)).unwrap(); } + let signer = Identity::generate().endpoint_id(); + // Query — should only get 5 (buffer evicted oldest). let resp = addr - .ask(WorkerRequestMsg(WorkerRequest::Sync { - server_id: "srv-1".to_string(), - heads: HeadsSummary::default(), - })) + .ask(WorkerRequestMsg { + req: WorkerRequest::Sync { + server_id: "srv-1".to_string(), + heads: HeadsSummary::default(), + }, + signer, + }) .await .unwrap(); match resp { @@ -812,6 +835,7 @@ impl TestHeadsRole { } } +#[async_trait::async_trait] impl willow_common::WorkerRole for TestHeadsRole { fn role_info(&self) -> willow_common::WorkerRoleInfo { willow_common::WorkerRoleInfo::Replay { @@ -824,8 +848,9 @@ impl willow_common::WorkerRole for TestHeadsRole { fn on_event(&mut self, _event: &willow_state::Event) {} - fn handle_request( + async fn handle_request( &mut self, + _signer: willow_identity::EndpointId, _req: willow_common::WorkerRequest, ) -> willow_common::WorkerResponse { willow_common::WorkerResponse::Denied { @@ -960,10 +985,13 @@ async fn sync_request_response_returns_known_events() { // A peer that has no events sends Sync with empty heads. let resp = state_addr - .ask(WorkerRequestMsg(willow_common::WorkerRequest::Sync { - server_id: "srv-conv".to_string(), - heads: willow_state::HeadsSummary::default(), - })) + .ask(WorkerRequestMsg { + req: willow_common::WorkerRequest::Sync { + server_id: "srv-conv".to_string(), + heads: willow_state::HeadsSummary::default(), + }, + signer: Identity::generate().endpoint_id(), + }) .await .unwrap(); From 9183e5324ee13ed205979e8264baeccac1e287cc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 06:53:36 +0000 Subject: [PATCH 26/27] feat(feedback): scaffold willow-feedback crate https://claude.ai/code/session_01F3RA1a1rcNxM83ZsQPnTZX --- Cargo.lock | 90 ++++++++++++++++++++++++++++++++++++- crates/feedback/Cargo.toml | 36 +++++++++++++++ crates/feedback/build.rs | 22 +++++++++ crates/feedback/src/lib.rs | 2 + crates/feedback/src/main.rs | 9 ++++ 5 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 crates/feedback/Cargo.toml create mode 100644 crates/feedback/build.rs create mode 100644 crates/feedback/src/lib.rs create mode 100644 crates/feedback/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 7a539de8..6b60145e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1373,6 +1373,17 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -2851,7 +2862,10 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ + "bitflags", "libc", + "plain", + "redox_syscall 0.7.4", ] [[package]] @@ -3522,7 +3536,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -3622,6 +3636,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plist" version = "1.8.0" @@ -4150,6 +4170,15 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_syscall" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -4621,6 +4650,15 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -5595,6 +5633,27 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tracing-test" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a4c448db514d4f24c5ddb9f73f2ee71bfb24c526cf0c570ba142d1119e0051" +dependencies = [ + "tracing-core", + "tracing-subscriber", + "tracing-test-macro", +] + +[[package]] +name = "tracing-test-macro" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "tracing-wasm" version = "0.2.1" @@ -6129,6 +6188,35 @@ dependencies = [ "zeroize", ] +[[package]] +name = "willow-feedback" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "blake3", + "bytes", + "clap", + "filetime", + "rand 0.8.5", + "regex", + "reqwest 0.12.28", + "secrecy", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", + "tracing-test", + "url", + "willow-common", + "willow-identity", + "willow-network", + "willow-state", + "willow-worker", +] + [[package]] name = "willow-identity" version = "0.1.0" diff --git a/crates/feedback/Cargo.toml b/crates/feedback/Cargo.toml new file mode 100644 index 00000000..dcc27531 --- /dev/null +++ b/crates/feedback/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "willow-feedback" +version = "0.1.0" +edition.workspace = true + +[[bin]] +name = "willow-feedback" +path = "src/main.rs" + +[dependencies] +willow-common = { path = "../common" } +willow-identity = { path = "../identity" } +willow-network = { path = "../network" } +willow-state = { path = "../state" } +willow-worker = { path = "../worker" } + +anyhow = { workspace = true } +async-trait = { workspace = true } +blake3 = { workspace = true } +bytes = { workspace = true } +clap = { version = "4", features = ["derive"] } +filetime = "0.2" +rand = { version = "0.8", features = ["std", "std_rng"] } +regex = "1" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +secrecy = "0.10" +serde = { workspace = true } +serde_json = "1" +thiserror = { workspace = true } +tokio = { workspace = true, features = ["full"] } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +url = "2" + +[dev-dependencies] +tracing-test = "0.2" diff --git a/crates/feedback/build.rs b/crates/feedback/build.rs new file mode 100644 index 00000000..b690c5eb --- /dev/null +++ b/crates/feedback/build.rs @@ -0,0 +1,22 @@ +//! Inject `WILLOW_BUILD_SHA` via `option_env!` so diagnostics can +//! surface the short git SHA. Best-effort: empty in dev builds. + +use std::process::Command; + +fn main() { + println!("cargo:rerun-if-env-changed=WILLOW_BUILD_SHA"); + if std::env::var_os("WILLOW_BUILD_SHA").is_some() { + return; // caller already set it + } + if let Ok(out) = Command::new("git") + .args(["rev-parse", "--short", "HEAD"]) + .output() + { + if out.status.success() { + let sha = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !sha.is_empty() { + println!("cargo:rustc-env=WILLOW_BUILD_SHA={sha}"); + } + } + } +} diff --git a/crates/feedback/src/lib.rs b/crates/feedback/src/lib.rs new file mode 100644 index 00000000..efd311dd --- /dev/null +++ b/crates/feedback/src/lib.rs @@ -0,0 +1,2 @@ +//! Willow feedback worker library. Modules added in subsequent +//! plan tasks. diff --git a/crates/feedback/src/main.rs b/crates/feedback/src/main.rs new file mode 100644 index 00000000..c8f24d8a --- /dev/null +++ b/crates/feedback/src/main.rs @@ -0,0 +1,9 @@ +//! Willow Feedback Node — stub. +//! +//! Filled in by Task 2.10. This stub exists so the crate compiles +//! while earlier modules are being built. + +fn main() { + eprintln!("willow-feedback: not yet implemented"); + std::process::exit(1); +} From 461c571cea14835777b963a048aeedf509a46936 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 07:02:09 +0000 Subject: [PATCH 27/27] feat(feedback): add body + title sanitization https://claude.ai/code/session_01F3RA1a1rcNxM83ZsQPnTZX --- crates/feedback/src/lib.rs | 5 +- crates/feedback/src/sanitize.rs | 225 ++++++++++++++++++++++++++++++++ 2 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 crates/feedback/src/sanitize.rs diff --git a/crates/feedback/src/lib.rs b/crates/feedback/src/lib.rs index efd311dd..a3601560 100644 --- a/crates/feedback/src/lib.rs +++ b/crates/feedback/src/lib.rs @@ -1,2 +1,3 @@ -//! Willow feedback worker library. Modules added in subsequent -//! plan tasks. +//! Willow feedback worker library. + +pub mod sanitize; diff --git a/crates/feedback/src/sanitize.rs b/crates/feedback/src/sanitize.rs new file mode 100644 index 00000000..d6ea6fe2 --- /dev/null +++ b/crates/feedback/src/sanitize.rs @@ -0,0 +1,225 @@ +//! User-supplied content sanitization for feedback issues. +//! +//! - `wrap_body_fenced` wraps the user body in a backtick code block +//! long enough that no closing fence inside the body can escape. +//! - `sanitize_title` strips control / bidi codepoints and escapes +//! leading brackets so the assembled title can't impersonate the +//! metadata block. + +use regex::Regex; +use std::sync::OnceLock; + +/// Match a CommonMark closing-fence line for backtick fences: +/// 0–3 leading spaces, three or more backticks, optional trailing +/// whitespace, end of line. +fn close_fence_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"^[ ]{0,3}(`{3,})[ \t]*$").unwrap()) +} + +/// Wrap `body` in a backtick fenced markdown block with the `text` +/// info-string. Fence length is the smallest N ≥ 3 such that no line +/// in the body is `^[ ]{0,3}` `` ` ``{N,}` `[ \t]*$` AND no run of +/// backticks anywhere in the body is ≥ N — guaranteeing no body +/// content can close our fence. +/// +/// CRLF line endings are normalized to LF before scanning and in the +/// output. +pub fn wrap_body_fenced(body: &str) -> String { + let body = body.replace("\r\n", "\n"); + let mut max_run: usize = 0; + for line in body.split('\n') { + if let Some(c) = close_fence_re().captures(line) { + let n = c.get(1).unwrap().as_str().len(); + if n > max_run { + max_run = n; + } + } + } + let mut current_run: usize = 0; + for ch in body.chars() { + if ch == '`' { + current_run += 1; + if current_run > 3 && current_run > max_run { + max_run = current_run; + } + } else { + current_run = 0; + } + } + let fence_len = std::cmp::max(3, max_run + 1); + let fence = "`".repeat(fence_len); + format!("{fence}text\n{body}\n{fence}") +} + +/// Sanitize a feedback title. Strips ASCII control codepoints +/// (0x00–0x1F, 0x7F) and Unicode bidi/RTL override codepoints +/// (U+202A..=U+202E, U+2066..=U+2069). Collapses internal +/// runs of whitespace to single spaces. Escapes `[` and `]` +/// with a backslash so the assembled title can't impersonate the +/// worker's metadata-block prefix. +pub fn sanitize_title(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + let mut last_was_ws = false; + for ch in raw.chars() { + let c = ch as u32; + let is_ascii_control = c <= 0x1F || c == 0x7F; + let is_bidi_override = matches!(c, 0x202A..=0x202E | 0x2066..=0x2069); + if is_ascii_control || is_bidi_override { + continue; + } + if ch.is_whitespace() { + if !last_was_ws && !out.is_empty() { + out.push(' '); + } + last_was_ws = true; + } else { + last_was_ws = false; + if ch == '[' { + out.push_str(r"\["); + } else if ch == ']' { + out.push_str(r"\]"); + } else { + out.push(ch); + } + } + } + out.trim_end().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Helper: assert the wrapped body is well-formed and the inner + /// content survives byte-for-byte (modulo CRLF normalization). + fn assert_wrap_round_trips(input: &str) { + let wrapped = wrap_body_fenced(input); + let normalized = input.replace("\r\n", "\n"); + assert!(wrapped.starts_with('`'), "must open with backticks"); + assert!( + wrapped.contains(&normalized), + "wrapped body must contain the normalized input verbatim" + ); + } + + #[test] + fn wraps_plain_body_with_min_three_backticks() { + let out = wrap_body_fenced("hello world"); + assert!(out.starts_with("```text\n")); + assert!(out.ends_with("\n```")); + } + + #[test] + fn escapes_body_containing_three_backticks() { + let body = "code: ```\nrust\n```\nend"; + let out = wrap_body_fenced(body); + // Must use at least 4 backticks since body has runs of 3. + assert!(out.starts_with("````text\n")); + assert!(out.ends_with("\n````")); + assert!(out.contains(body)); + } + + #[test] + fn handles_indented_closing_fence() { + // Up-to-3-space indent counts as a valid close per CommonMark. + let body = "stuff\n ```\nmore"; + let out = wrap_body_fenced(body); + assert!(out.starts_with("````text\n")); + } + + #[test] + fn ignores_four_space_indent() { + // 4+ spaces before backticks is a code block, not a fence. + let body = "stuff\n ```\nmore"; + let out = wrap_body_fenced(body); + assert!(out.starts_with("```text\n"), "no escalation needed"); + } + + #[test] + fn handles_crlf_line_endings() { + let body = "line1\r\n```\r\nline3"; + let out = wrap_body_fenced(body); + assert!(out.starts_with("````text\n")); + // Wrapped output uses LF only. + assert!(!out.contains("\r\n")); + } + + #[test] + fn ignores_tilde_fences() { + let body = "~~~\nhi\n~~~"; + let out = wrap_body_fenced(body); + assert!(out.starts_with("```text\n"), "tildes don't close backticks"); + } + + #[test] + fn handles_info_string_after_fence() { + // ```text on its own line is a CLOSE if it's just backticks + // and whitespace; with `text` after, it's an open. Sanitizer + // must still escalate because the regex `^[ ]{0,3}` `` ` ``{N,}` `[ \t]*$` + // only matches *closing* fences. + let body = "stuff\n```\nmore"; // bare close + let out = wrap_body_fenced(body); + assert!(out.starts_with("````text\n")); + + let body2 = "stuff\n```rust\nmore"; // not a close + let out2 = wrap_body_fenced(body2); + assert!(out2.starts_with("```text\n")); + } + + #[test] + fn html_entity_backticks_dont_escape() { + // HTML entities are rendered as text inside fenced blocks, so + // they don't escape — sanitizer doesn't need to do anything. + let body = "```\n`code`\n```"; + let out = wrap_body_fenced(body); + assert!(out.starts_with("```text\n")); + assert!(out.contains(body)); + } + + #[test] + fn five_backticks_in_body_escalates_to_six() { + let body = "weird: `````".to_string(); + let out = wrap_body_fenced(&body); + assert!(out.starts_with("``````text\n"), "got: {}", out); + } + + #[test] + fn wrap_round_trips_assorted_inputs() { + for s in [ + "", + "hello", + "@everyone please look", + "![pixel](https://attacker/?ip=)", + "", + "[link](javascript:alert(1))", + "#1 issue cross-ref", + ] { + assert_wrap_round_trips(s); + } + } + + #[test] + fn sanitize_title_strips_controls() { + let raw = "hello\u{0007}world\u{0001}"; + assert_eq!(sanitize_title(raw), "helloworld"); + } + + #[test] + fn sanitize_title_strips_bidi_overrides() { + let raw = "hello\u{202E}evil"; + assert_eq!(sanitize_title(raw), "helloevil"); + } + + #[test] + fn sanitize_title_collapses_internal_whitespace() { + let raw = "hello \tworld bar"; + assert_eq!(sanitize_title(raw), "hello world bar"); + } + + #[test] + fn sanitize_title_escapes_leading_brackets() { + assert_eq!(sanitize_title("[bug] crash"), r"\[bug\] crash"); + assert_eq!(sanitize_title("]nope"), r"\]nope"); + } +}