docs(guide): add technical.feedback — the last uncovered service

hiveops-feedback has been live in production since 2026-07-09 and had no Guide
module; coverage-reconcile.py has been reporting it as MISSING. Coverage is now
green: 127 modules across 19 apps, no missing services/agent modules/SPAs.

Written from source (develop @ ea81eb4), matching the technical.* shape:
single overview.md, tab=Overview, audience=dev, order=10.

Records the things that actually cost time rather than restating the obvious:

- Split DB vars (DB_HOST/DB_PORT/DB_NAME), NOT DB_URL like journal/devices.
- Two auth realms: X-Submit-Key -> ROLE_SUBMITTER for intake; JWT MSP/BCOS for
  triage. RELOCATED is terminal and immutable (409 on accept/review).
- Gitea relay: label lookups swallow their exceptions, so a token without
  read:organization silently drops org labels (feedback#14).
- Rate limiting trusts X-Forwarded-For blindly and is per-instance Caffeine
  (feedback#5). /h2-console/** is permitAll unconditionally (feedback#7).
- The list endpoint has NO institution scoping — every MSP admin sees every
  customer's submissions, and QDS log in as MSP_ADMIN (feedback#19, unfixed).
- Accept never worked in prod until 2026-07-16: %2F path -> 405, then an empty
  GITEA_TOKEN -> unauthenticated -> Gitea 404 on a private repo -> opaque 500.
- No Kafka, no close-sync with Gitea.

Refs #26
This commit is contained in:
2026-07-17 09:06:58 -04:00
parent 9bb8d8b579
commit a7cd8e8b6e
@@ -0,0 +1,92 @@
---
module: technical.feedback
title: Feedback — customer feedback intake & triage relay to Gitea
tab: Overview
order: 10
audience: dev
---
> **DRAFT · internal.** Written 2026-07-17 from hiveops-feedback source (`develop` @ `ea81eb4`). Verify against code before relying on specifics.
## Responsibility
`hiveops-feedback` (Spring Boot backend, artifact `hiveops-feedback`, port **8098**) is the intake and triage desk for customer-reported feedback. The HiveIQ Browser's feedback widget POSTs a submission; MSP/BCOS admins triage it in the SPA (`feedback.bcos.cloud`, port 5190); accepting one **creates a Gitea issue** and links it back to the submission.
It exists so **customers never need Gitea access**. The privileged Gitea token lives server-side in this service only and is never shipped to a client. QDS staff file feedback from inside the product; engineering gets a normal Gitea issue.
Scope is deliberately small: one table, no Kafka, no scheduler, no outbound mail. It is an intake queue with a relay, not a ticketing system — Gitea remains the system of record once a submission is accepted.
## Data
- **Database:** `hiveiq_feedback`. The `prod` profile builds the URL from **split vars** — `jdbc:postgresql://${DB_HOST}:${DB_PORT:5432}/${DB_NAME:hiveiq_feedback}` — *not* a single `DB_URL` (unlike [technical.devices] / [technical.journal]). `ddl-auto=validate`; schema is **Flyway** (`V1__init` only). The `dev` profile runs **H2 in-memory** (`MODE=PostgreSQL`) with the H2 console enabled. See [platform.data-stores].
- **One table:**
| Table | Entity | Purpose |
|-------|--------|---------|
| `feedback_submissions` | `FeedbackSubmission` | Every submission + its triage outcome and Gitea linkage |
Columns of note: `module`, `type`, `title`, `body`, `reporter_email`, `reporter_name`, `app_version`, `os`, `platform`, `status`, `gitea_repo`, `gitea_issue_number`, `gitea_issue_url`, `reviewer`, `review_notes`, `created_at`, `updated_at`.
**Enums:**
- `FeedbackType`: `BUG` · `ENHANCEMENT`
- `SubmissionStatus`: `NEW` · `TRIAGED` · `RELOCATED` · `REJECTED` · `DUPLICATE` · `NEEDS_INFO` · `TRAINING` · `RESOLVED`
`RELOCATED` is the terminal success state — it means a Gitea issue exists and `gitea_issue_url` is populated.
## Kafka
**None.** This service neither produces nor consumes. It is HTTP-only in both directions (widget in, Gitea out). See [platform.kafka].
## API surface
All paths are under `@RequestMapping("/feedback/api")`. Externally: `https://api.bcos.cloud/feedback/...` — nginx **preserves** the `/feedback` prefix (`proxy_pass http://$backend/feedback/$1`), unlike the Guide's own route which strips it.
| Method + Path | Auth | Purpose |
|---------------|------|---------|
| POST `/feedback/api/submit` | `ROLE_SUBMITTER` (shared key) | Widget intake → 201 `{id, status}` |
| GET `/feedback/api/submissions` | MSP_ADMIN / BCOS_ADMIN | Triage queue (paged; `status`, `type`, `module` filters) |
| GET `/feedback/api/submissions/{id}` | MSP_ADMIN / BCOS_ADMIN | One submission |
| POST `/feedback/api/submissions/{id}/accept` | MSP_ADMIN / BCOS_ADMIN | **Creates a Gitea issue** → `RELOCATED` |
| POST `/feedback/api/submissions/{id}/reject` | MSP_ADMIN / BCOS_ADMIN | → `REJECTED` |
| POST `/feedback/api/submissions/{id}/needs-info` | MSP_ADMIN / BCOS_ADMIN | → `NEEDS_INFO` |
| POST `/feedback/api/submissions/{id}/duplicate` | MSP_ADMIN / BCOS_ADMIN | → `DUPLICATE` (tracked on an existing issue) |
| POST `/feedback/api/submissions/{id}/training` | MSP_ADMIN / BCOS_ADMIN | → `TRAINING` (training gap, not a defect) |
| POST `/feedback/api/submissions/{id}/resolve` | MSP_ADMIN / BCOS_ADMIN | → `RESOLVED`, **no issue created** (handled directly / already fixed) |
**`AcceptRequest`** = `{repo, labels[], assignee, priority, notes}`. A bare `repo` gets the `hiveiq-src` org prefix. `priority` is merged into `labels` — it is a triage output, not something the customer supplies. All other actions take `ReviewRequest` = `{notes}`.
### Two auth realms
| Realm | Mechanism | Class |
|-------|-----------|-------|
| Intake | `X-Submit-Key` header → `ROLE_SUBMITTER` | `SubmitKeyFilter` |
| Triage | JWT → `MSP_ADMIN` / `BCOS_ADMIN` | `JwtAuthenticationFilter` |
`permitAll`: `/actuator/health`, `/actuator/info`, `/error`, **and `/h2-console/**`** (see Gotchas).
## Gitea relay
`GiteaService` is the only holder of the privileged token. On `accept()`:
1. `normalizeRepo` — bare name gets the `hiveiq-src` prefix.
2. `resolveLabelIds` — label **names** → ids, looked up at **repo** scope then **org** scope (`/repos/{full}/labels`, then `/orgs/{owner}/labels`).
3. `SanitizerService.sanitize` runs over the body.
4. POST `/repos/{fullRepo}/issues`, then the submission is stamped `RELOCATED` + `gitea_issue_url`.
Config: `gitea.base-url` (`GITEA_BASE_URL`), `gitea.token` (`GITEA_TOKEN`), `gitea.org` (`GITEA_ORG`, default `hiveiq-src`), `gitea.insecure-tls` (`GITEA_INSECURE_TLS`, default `true` — self-signed internal cert).
## Gotchas
- **Split DB vars, not `DB_URL`.** The `prod` profile composes the JDBC URL from `DB_HOST`/`DB_PORT`/`DB_NAME`. Copying [technical.journal]'s `DB_URL` pattern here will not work.
- **`accept()` calls Gitea BEFORE mutating the entity**, and is `@Transactional` — so a Gitea failure persists nothing. Safe to retry. Both `accept()` and `review()` throw (409) once a submission is `RELOCATED`: that state is immutable by design.
- **⚠️ `POST`/`DELETE` label lookups are silently lossy.** `collectLabels` swallows its exceptions (`log.debug`), so if the token cannot read `/orgs/{owner}/labels` the org-level labels are **dropped without error** and the issue is created with fewer labels than asked for. A dedicated token therefore needs **`read:organization`** as well as `write:issue` (feedback#14).
- **⚠️ Rate limiting trusts `X-Forwarded-For` blindly.** `clientIp()` takes `xff.split(",")[0]` with no trusted-proxy check, so the 20-per-60s budget (`FEEDBACK_RATE_MAX` / `FEEDBACK_RATE_WINDOW_SECONDS`) is trivially bypassed by spoofing the header (feedback#5). The limiter is an in-memory Caffeine cache — **per-instance**, not shared; it resets on restart and does not survive scaling out.
- **⚠️ `/h2-console/**` is `permitAll` in `SecurityConfig`** — unconditionally, not profile-gated. The console itself is only *enabled* on the `dev` profile, so prod does not serve it, but the permit rule ships regardless (feedback#7).
- **⚠️ The list endpoint has NO institution scoping.** `GET /feedback/api/submissions` is gated on `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` and returns **every** row to **every** MSP admin. `FeedbackPrincipal` carries `institutionKey` but the query never consults it. QDS log in as MSP_ADMIN ([reference.qds-is-msp-admin]) — so any MSP admin can read every other customer's submissions. This caused a real exposure on 2026-07-09 and the imported rows were purged (feedback#19). **Still unfixed.**
- **`POST /submit` carries no user JWT** — it authenticates with a *shared* key, so a submission has no institution attached at all. That is why #19 cannot be fixed by scoping the read alone.
- **The submit key is baked into the Browser at build time.** `FEEDBACK_SUBMIT_KEY` on the server must equal the value the shipped Browser carries — the server has to adopt the client's key, not the reverse, because you cannot push a new key to already-installed clients without a rebuild.
- **`SanitizerService` is a second PAN masker.** It is Luhn-validated, keeps the last 4, and is deliberately conservative — and is **not** the same code as hiveops-journal's `PciSanitizer`. Two implementations of the same rule; reconcile if a shared library is ever extracted. See [platform.pci-scope].
- **Accept had never worked in production until 2026-07-16.** Two stacked causes: a `%2F`-encoded repo path made Gitea return 405 (fixed `f06528f`), and `GITEA_TOKEN` was empty — so the client called Gitea unauthenticated, Gitea answered **404** (not 403) on the private repo, and `GlobalExceptionHandler` surfaced it as an opaque **500**. Both resolved; the token is now wired from `.env`.
- **`NoResourceFoundException` → 500.** `GlobalExceptionHandler` turns a wrong URL into a server error rather than a 404. A typo'd path looks like an outage. Same pattern exists in the Guide's own handler.
- **No close-sync with Gitea.** Nothing polls issue state, and there is no `CLOSED` status or delete endpoint — a `RELOCATED` row stays on the list forever after its Gitea issue closes. Pruning is manual.