Skip to content

HAR-21: Survey module — DB-backed 1–5 surveys (API, CLI, bridge, widget)#78

Merged
unrealrobin merged 6 commits into
mainfrom
har-21-survey-api
Jun 10, 2026
Merged

HAR-21: Survey module — DB-backed 1–5 surveys (API, CLI, bridge, widget)#78
unrealrobin merged 6 commits into
mainfrom
har-21-survey-api

Conversation

@unrealrobin

@unrealrobin unrealrobin commented Jun 9, 2026

Copy link
Copy Markdown
Owner

Summary

Adds the Survey module (HAR-21) end-to-end: DB-backed 1–5 rating surveys (Deadlock-style), opt-in and default-off, authored by developers in the CLI and rated inline by players in the launcher. Built in five slices on one branch:

  • Shared types (crates/harbor-types) — ModuleType::Survey + PublicSurvey / VoteRequest wire types (bare record-key ids).
  • API (apps/api) — survey + survey_response tables with a UNIQUE(survey, voter_id) dedup index; authed create / list / results / close (all ownership-checked) and public list / vote endpoints; app_id copied server-side, 4KiB public body cap, idempotent duplicate votes, 409 on closed surveys.
  • CLI (apps/cli) — create a survey, view results (1–5 histogram + average + total), and close it from the REPL; survey wired into the module on/off surface.
  • Launcher bridge (apps/harbor/src-tauri) — persistent install-id voter dedup (UUID at %LOCALAPPDATA%\Paracosm\Harbor\data\), blocking survey client (list / vote / dismiss) with a local seen-set, three thin Tauri commands. Launcher bumped 0.2.8 → 0.3.0.
  • Launcher frontend (apps/harbor/src) — typed invoke wrappers, useActiveSurveys queue hook, and SurveyWidget rendered as the top card of the home feed, styled to match HomeCardList and themed from the config's --primary / --accent.

The public vote endpoint is intentionally unauthenticated; rate limiting / abuse hardening is tracked separately as HAR-147 (deploy-gating, not in this PR).

Test plan

  • cargo test --workspace (Rust) and npm test (Vitest, 113) green; cargo clippy --workspace -- -D warnings clean; npm run build clean.
  • Each slice passed the per-task review pipeline (test-writer → performance-reviewer → security-auditor → rust-reviewer). Security audit confirmed the voter id never reaches logs/Sentry/webview and remote survey strings render as React text nodes only.
  • Full local end-to-end manual QA against a local API + SurrealDB: created a survey in the CLI → widget renders in the launcher home stack → rated it → vote shows in CLI results. Verified: N / M queue stepping; install-dedup across restarts; close-while-open race advances cleanly with nothing counted; API down → home page and Play button unaffected, widget silently absent; module toggle hides the widget; named vs. unnamed 1/5 anchors keep an identical box height.

Linked Issues

HAR-21

## What changed
- apps/api/src/db.rs — migrate() defines the `survey` and `survey_response`
  tables: survey holds prompt + optional 1-5 anchor labels + status;
  survey_response holds one row per rating, with a UNIQUE(survey, voter_id)
  dedup index, a (survey) tally index, and a DB ASSERT that rating is 1..=5
- apps/api/src/routes/surveys/models.rs (new) — the `Survey` DB row and a
  pure `SurveyTally` aggregator (zero-filled 1-5 distribution + average +
  total) with unit tests
- apps/api/src/routes/surveys/mod.rs (new) — two routers, six handlers:
  authed (AuthDeveloper, ownership-checked) create / list / results / close;
  public/unauthenticated list-active and submit-vote. Plus input validators
  (prompt, anchors, voter_id, rating, survey-id shape) and ownership /
  record-id / unique-error helpers
- apps/api/src/routes/mod.rs — register `pub mod surveys;`
- apps/api/src/main.rs — nest `/surveys` (authed) and `/public/surveys` (public)

## Why
PR 2 of the Survey module (HAR-21): the backend. Surveys are DB-backed (not
CDN), so this adds the schema and the API that both the CLI (developer) and
the launcher (player) talk to.

The vote endpoint is Harbor's first public, unauthenticated player-side
write, so it is hardened per the security review: every query value is
parameterized (no injection); authed handlers filter by owner_id and 404
(not leak) on another developer's survey id; voter_id is never logged (the
vote error path logs a category, not the raw DB string); inputs are
validated before any DB write; a malformed survey id fast-fails to a clean
404 instead of a DB round-trip + 500; and the public body size is capped.

Dedup is honest-client only — UNIQUE(survey, voter_id) gives one rating per
install for a real launcher, but voter_id is client-supplied, so it is not
ballot-stuffing-resistant. Per-IP rate limiting (which also covers the
identically-exposed /analytics/event) is tracked as a separate deploy-gating
item before this endpoint goes public. Runtime DB-query behavior (the GROUP
BY tally, type::record id loading, the dedup 200 path) is to be confirmed by
manual QA against a live SurrealDB; the unit tests here cover the validators
and the pure tally fold.
@unrealrobin unrealrobin marked this pull request as draft June 9, 2026 04:54
## What changed
- apps/api/src/routes/surveys/mod.rs — the public survey id is now the bare
  record key (e.g. `k3f9x`) instead of the full `survey:k3f9x`, so URLs read
  `/surveys/{key}/...` with no colon. Added a `survey_key` helper (strips the
  `survey:` prefix for the wire) and reworked `load_survey` to rejoin the key
  and load via `type::record` — SurrealDB 3.x renamed `type::thing` →
  `type::record`. Renamed `is_valid_survey_id` → `is_valid_survey_key`.
- apps/api/src/routes/surveys/mod.rs — new `#[cfg(test)] mod integration`: a
  `#[tokio::test]` that spins up an in-memory SurrealDB (the kv-mem feature),
  migrates it, seeds a game, and walks the full flow through the real handlers
  (create → vote → re-vote/dedup → results → close → 409 → active-list),
  asserting the tally/average/distribution and the idempotent dedup.

## Why
Two review-driven changes: (1) the doubled `surveys/survey:...` was redundant,
and the colon in the URL was the single riskiest unverified bit (axum routing
+ the HTTP client). The bare key removes both — cleaner URLs and one fewer
failure mode. (2) The codebase had zero integration tests, so the actual
SurrealDB queries (type::record loads, the GROUP BY tally, the unique-index
dedup) were unexercised — compiling and unit tests can't catch a wrong query.
The new in-memory suite closes that gap, establishes the first integration
pattern for the repo, and immediately caught a real bug: `type::thing` does
not exist in SurrealDB 3.x.
## What changed
- apps/cli/src/commands/survey.rs (new) — run_create (draft a 1-5
  prompt with optional low/high anchor labels, confirm, then
  POST /surveys) and run_results (list -> select -> render a text
  histogram with average + total, then optionally close the survey,
  folding "stop survey" into the results view). Pure render_results
  helper is unit-tested.
- apps/cli/src/api/client.rs — survey DTOs (CreateSurveyRequest,
  SurveySummary, SurveyResults, SurveyRatingCount), a json_or_bail
  helper, and four methods: create_survey, list_surveys,
  survey_results, close_survey. list_surveys builds the ?app_id=
  query directly in the URL because reqwest 0.13 under our trimmed
  feature set exposes no RequestBuilder::query; app_id is a
  server-issued studio/game slug so it needs no percent-encoding.
- apps/cli/src/validation.rs — validate_survey_prompt (<=200) and
  validate_survey_anchor (<=80), char-counted to match the API's
  bounds exactly; +11 tests including a multi-byte char case.
- apps/cli/src/commands/mod.rs — register pub mod survey.
- apps/cli/src/session/{menu,state,session_loop,suggestion}.rs — add
  MenuId/ActionKind SurveyCreate + SurveyResults gated on
  logged_in && has_config, dispatch to the new commands, menu labels
  and next-step hints; updated the menu-vector and suggestion tests.
- apps/cli/src/commands/init.rs — add Survey to ALL_MODULE_TYPES /
  MODULE_LABELS and a default_enabled() helper so Survey is opt-in
  (default-off) while the three content modules stay default-on.
- apps/cli/src/commands/configure.rs — add Survey to the Modules
  toggle diff and the section label.
- apps/cli/Cargo.toml — bump harbor-cli 1.0.3 -> 1.1.0.
- apps/api/src/routes/content.rs — drive-by: a pre-existing
  clippy assertions_on_constants lint (surfaced by clippy 1.92.0)
  blocked the workspace gate. Move an all-const invariant in a
  content test into a const { assert!(...) } block (compile-time
  check). Unrelated to surveys; bundled so the branch gate is green
  in this push rather than a separate cycle.

## Why
This wires the developer side of the Survey module (HAR-21) into the
CLI so a survey can be walked start to finish: create a prompt, watch
responses as a 1-5 histogram with the average, and stop it -- all from
the harbor session menu. Surveys are DB-backed, so these talk to the
API's /surveys endpoints rather than the presigned-URL push flow.

Validators mirror the API's char-count limits so an overlong prompt is
rejected at the prompt instead of round-tripping to a 400. Survey is
wired into the same module on/off surface as the other modules
(requirement 6) but defaults off -- a new game shouldn't surface a
survey widget until the developer deliberately enables it and authors a
question, matching default_modules() in harbor-types.

The CLI version bump reflects new user-visible functionality. Per the
feature's merge policy this lands as a commit on the shared
har-21-survey-api branch only -- no PR flip until the whole module
(CLI + launcher bridge + frontend) works end-to-end.
## What changed
- apps/harbor/src-tauri/src/install_id.rs (new) — persistent install
  UUID at %LOCALAPPDATA%\Paracosm\Harbor\data\install_id, cached in a
  OnceLock; corrupt content regenerated; FS failure degrades to a
  process-ephemeral id so surveys never block launcher UX
- apps/harbor/src-tauri/src/surveys.rs (new) — on-demand survey client:
  list_active GETs /public/surveys and filters out locally-seen ids;
  submit_vote validates rating/key, attaches the voter id, and treats
  200 and 409 (closed-since-fetch) both as "finished — advance" and
  marks the survey seen; dismiss records a Skip locally only. Seen-set
  persists at ...\data\surveys_seen.json under a poison-recovering
  Mutex. HARBOR_API_BASE override honored via analytics::api_base()
- apps/harbor/src-tauri/src/lib.rs — three thin async commands
  (list_active_surveys, submit_survey_vote, dismiss_survey) wrapping
  tauri::async_runtime::spawn_blocking; registered in
  generate_handler!. Survey instrument spans are debug-level, with
  rating and the raw dismiss input skipped, so player response data
  can never reach Sentry transactions
- apps/harbor/src-tauri/src/analytics.rs — api_base() is now
  pub(crate), shared with surveys.rs
- launcher version 0.2.8 -> 0.3.0 in apps/harbor/package.json,
  src-tauri/Cargo.toml, and tauri.conf.json (+ Cargo.lock)
- riding small fixes: stale type::thing doc comment in
  apps/api/src/routes/surveys/mod.rs; bare-key doc wording and test
  fixtures in crates/harbor-types/src/survey.rs;
  .claude/settings.local.json permission noise

## Why
Fourth slice of HAR-21 (the "PR 4 — Launcher bridge" checklist on the
Linear issue): the launcher side of the survey loop, so the upcoming
frontend slice only has to consume typed commands. One deliberate
deviation from the checklist: submit_vote takes no app_id parameter —
the API ignores client-supplied app_id and copies it from the survey
row server-side, so passing it would only suggest a trust relationship
that doesn't exist.

The seen-set is a UX convenience (which surveys to SHOW); the API's
UNIQUE(survey, voter_id) index remains the real dedup (which ratings
to COUNT) — deleting local state merely re-shows surveys and repeat
ratings are idempotent 200s. The review pipeline shaped three details:
a shared OnceLock reqwest client (votes are player-watched, pooling
saves a TCP+TLS handshake per click), debug-level spans with
skip(rating) (INFO command spans become sampled Sentry transactions
and #[instrument] auto-captures all args — ratings must not leave the
machine outside the vote body), and webview-input validation before
any URL build or log line. Known follow-up: install_id::data_dir()
re-derives the data root that main.rs::log_dir also derives;
centralizing needs a main.rs touch and is deferred.
## What changed
- apps/harbor/src/types/survey.ts (new) — PublicSurvey mirroring the
  Rust wire type (camelCase; optional lowLabel/highLabel)
- apps/harbor/src/types/config.ts — ModuleConfig.type union gains
  "survey"
- apps/harbor/src/api/surveys.ts (new) — typed invoke wrappers:
  listActiveSurveys (silent-fail to []), submitSurveyVote (bool),
  dismissSurvey (fire-and-forget); snake_case arg keys pinned by tests
- apps/harbor/src/api/analytics.ts — recordSurveyRendered: fires
  ModuleRendered{survey} directly (surveys have no ContentDir)
- apps/harbor/src/hooks/useActiveSurveys.ts (new) — fetches the queue
  once per session, steps through it (survey/index/total), optimistic
  vote/skip that advance immediately and POST fire-and-forget
- apps/harbor/src/components/SurveyWidget.tsx (new) — presentational
  Deadlock-style card: SURVEY eyebrow, N / M counter (hidden when 1),
  prompt, 1-5 button row, end-anchor labels, Skip. All remote-origin
  strings render as React text nodes only (csp: null webview)
- apps/harbor/src/App.tsx — calls useActiveSurveys (gated on the
  module toggle), mounts the widget in the left column above HeroArea
- apps/harbor/src/api/surveys.test.ts (new) + analytics.test.ts —
  10 tests pinning command names, snake_case keys, and the
  never-throws contracts

## Why
Final slice of HAR-21 (the "PR 5 — Launcher frontend" checklist on the
Linear issue): players can now see and rate surveys in the launcher,
closing the loop the API, CLI, and bridge slices built. Two deliberate
deviations from the checklist: submitSurveyVote takes no appId (the
bridge dropped it — the API resolves app_id server-side), and the
queue state lives in App rather than the widget. The home <main>
unmounts on tab navigation; holding the state in App means returning
from Patch Notes doesn't re-fetch, re-fire the impression analytic,
or reset the N / M position, and the widget stays purely
presentational. Votes advance optimistically (the result was ignored
by design anyway — failures aren't marked seen and re-show next
launch), so a dead network can't freeze the card for its 5s timeout.
The hook and widget have no unit tests: the repo has no React test
renderer (Vitest only, node environment) — covered by manual QA.
## What changed
- apps/harbor/src/components/SurveyWidget.tsx — rebuilt to match
  HomeCardList: same max-w-[500px] box, radius, surface, and blur. The
  SURVEY pill uses --primary and the rating buttons highlight with
  --accent on hover, so the widget carries the developer's theme. The
  optional 1/5 anchor labels now render inside the end buttons
  (absolute, truncated) instead of on their own row, so a survey that
  names its anchors is exactly as tall as one that doesn't.
- apps/harbor/src/App.tsx — moved the survey out of the left column
  into the right-hand home feed as the top card; the content feed below
  trims to 3 so the stack stays at most four boxes. The "no content"
  empty state is suppressed while a survey is showing.
- apps/harbor/src-tauri/Cargo.toml — line-ending normalization only
  (no content change).

## Why
Live QA feedback: the survey should read as one of the home "wide box"
modules, not a separate left-column panel — so it takes the top slot of
the existing card stack and matches their dimensions. Driving its colors
from the config's --primary/--accent keeps the developer's theme
consistent across the launcher. Anchoring the 1/5 labels inside the end
buttons rather than a dedicated row keeps the box a single fixed height
whether or not the developer names the scale ends (the explicit ask);
long labels truncate to the button width.
@unrealrobin unrealrobin changed the title HAR-21: add survey API domain (DB schema, routes, public vote endpoint) HAR-21: Survey module — DB-backed 1–5 surveys (API, CLI, bridge, widget) Jun 10, 2026
@unrealrobin unrealrobin marked this pull request as ready for review June 10, 2026 20:32
@unrealrobin unrealrobin merged commit 9a35f38 into main Jun 10, 2026
2 checks passed
@unrealrobin unrealrobin deleted the har-21-survey-api branch June 10, 2026 20:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant