neosourceDocs
Search docs

Submit a feedback report

POST/api/feedback

submitFeedback

Files a pre-alpha feedback report from the in-app launcher: what the reporter typed, the page they were on, and the console/request diagnostics the client buffered, plus an optional pasted screenshot as base64. Session-authenticated only — a scoped service token is refused. Diagnostics are capped server-side (body 4000 characters; the newest 200 console entries and 100 request entries) and over-cap payloads are TRUNCATED rather than rejected: the `truncated` object in the response says exactly what was dropped. The screenshot is the exception — it is refused with 415 if its type is not an allowlisted raster image (SVG is never accepted), 413 if it decodes to more than 4 MiB, and 400 if the bytes do not sniff as the type they declare. An accepted screenshot is METADATA-STRIPPED (EXIF/GPS included) and capped at 4096 pixels on its long edge before it is stored, so the raw upload never exists at rest. The stored content type is therefore what was actually WRITTEN, not what was declared — a downscaled WebP is stored as JPEG (or PNG when it has alpha) — and `size_bytes` is the size of the stored object. The 4 MiB limit is enforced on that stored object as well as on the upload, because a re-encode can be larger than its input.

Requires authentication using a session cookie — see tokens and scopes.

curl

curl -X POST 'https://neosource.dev/api/feedback' \
  -b 'ns_session=$NEOSOURCE_SESSION' \
  -H 'Content-Type: application/json' \
  -d '{"kind":"string","body":"string"}'

fetch

fetch("https://neosource.dev/api/feedback", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  credentials: "include",
  body: JSON.stringify({"kind":"string","body":"string"}),
});

Request bodyrequired

application/json

SubmitFeedbackRequest

object

Body of `POST /api/feedback`. **One request, not two.** The screenshot rides as base64 inside this JSON rather than as a second raw-body upload: the report is then atomic (no half-submitted row whose screenshot PUT failed), it stays on the `OpenAPI` surface so the generated clients cover it, and it needs no off-spec router merged outside `layer_api_middleware`. The price is 33% base64 inflation on a 4 MiB cap plus a buffered body, which is the right trade at this size — revisit if the cap ever grows past ~10 MiB.

bodyrequired

string

What the reporter typed. Truncated server-side rather than rejected — a rejected report is a lost report, and the reporter cannot fix a cap they cannot see. `SubmitFeedbackResponse::truncated` says what was cut.

console

array

Buffered console lines, oldest first. The server keeps the **newest** when it has to drop some: the lines near the bug are the useful ones.

items

ConsoleEntry

object

One buffered console line.

at_msrequired

integerint64

Epoch milliseconds, as the client's `Date.now()` reported them. Wall clock rather than `performance.now()` so a line can be matched against a server-side log entry — a monotonic clock is only ever comparable to itself. The cost is that a reporter's system clock may be skewed arbitrarily, so **the absolute value is never authoritative**: `AdminFeedbackDetail::created_at` is the server's own stamp and is the one to trust. The admin view renders these as offsets from the first captured line, which stays correct under any skew.

levelrequired

string

`log` / `info` / `warn` / `error`. The window `error` / `unhandledrejection` listeners record at `error` too, rather than inventing their own level — what distinguishes them is an `Uncaught ` / `Unhandled rejection: ` prefix on the message. Worth knowing when reading a report: an `error` line is not necessarily a `console.error` call. Deliberately an open `String`, not an enum: the vocabulary is the *browser's*, it grows (`console.debug`, `console.trace`, …), and a server-side closed set would reject a whole report over an unrecognised level name. That is the opposite of the truncate-don't-reject posture the rest of this surface takes. The admin view renders it as text.

messagerequired

string

The formatted line. The client stringifies arguments with a bounded serializer; the server caps the length again and does not trust it.

context

FeedbackContext

object

Where the reporter was when they filed. Every field is optional and defaulted because the widget must never fail to submit over a missing detail — a report with no viewport is still a report. `path` is a **pathname only**, never a full URL. A query string can carry credentials (`?exp&kid&sig` on our signed content URLs) and an operator reads this in the admin panel, so it must not become a credential sink. The submit route enforces that; this type only carries it. There is deliberately **no `workspace_id`**: it is a foreign key, and a client does not get to name one. The route resolves it from `owner`/`repo` through the normal catalog read, or leaves it null.

app_version

string | null

The SPA build id (`$app/environment`'s `version`). There is no server version endpoint, so this is the only way to know which bundle the reporter was running.

owner

string | null

Owner slug from the route, when the page had one.

path

string | null

`location.pathname` — no query, no fragment.

repo

string | null

Repository slug from the route, when the page had one.

route_id

string | null

`SvelteKit` route id (`/[owner]/[repo]/issues`). Groups reports by *screen* even when every concrete path is unique per repo, which is what makes "three people hit this on the issues list" visible at all.

theme

string | null

Active theme name — a surprising number of visual bugs are one theme only.

user_agent

string | null

viewport

string | null

`"1280x800"`.

kindrequired

FeedbackKind

string

What the reporter is telling us. Deliberately three coarse buckets rather than a taxonomy: the point of a pre-alpha widget is that filing costs one click, and a picker with eight options is a form.

"broken""works""idea"

requests

array

Buffered `/api/*` request records, oldest first — same direction.

items

RequestEntry

object

One buffered same-origin `/api/*` request. Metadata only, by construction: **never** a request or response body, and never a header — an `Authorization` header or a POST body would turn every bug report into a credential leak.

at_msrequired

integerint64

Epoch milliseconds — see [`ConsoleEntry::at_ms`] for why it is wall clock and why the absolute value is not authoritative.

duration_msrequired

integerint64

methodrequired

string

pathrequired

string

Pathname only, for the same reason as [`FeedbackContext::path`]: a query string can carry a signature or a token.

status

integer | nullint32

HTTP status, or `null` when the request never produced a response (network failure, abort, offline). That distinction is often the whole bug, so it is modelled rather than flattened onto a sentinel `0`.

screenshot_base64

string | null

Optional pasted screenshot, base64 (standard alphabet, no data-URL prefix). The only hard-reject in the whole submit path: it is optional, so failing it loudly costs the reporter nothing, whereas silently storing an unreadable blob costs the operator a triage cycle.

screenshot_content_type

string | null

MIME type of `screenshot_base64`, checked against the attachment image allowlist (no SVG — an SVG is a script container, and the admin origin serves this back) and then against the bytes themselves, which must sniff as the type they declare. **A deliberately open vocabulary, hence `String` and not an enum** (CLAUDE.md § Coding conventions). The allowlist is a runtime constant shared with attachment upload and grows with ADR 0043's list, and what is finally STORED is whatever `normalise_for_ingest` reports it wrote — which need not be what the client declared, since a downscaled WebP lands as JPEG or PNG. Closing this set in the wire type would freeze a list that moves and would still not describe the stored value.

Responses

201Report filed

application/json

SubmitFeedbackResponse

object

Response of `POST /api/feedback`. `truncated` is echoed back rather than being a server-side detail: the route truncates instead of rejecting, so this is the only way the reporter learns that half their console log did not ship — and the dialog can say so instead of implying everything they read was sent.

feedback_idrequired

string

truncatedrequired

FeedbackTruncation

object

What the server bounded while accepting a report, persisted to the `truncated` jsonb and echoed back to the submitter. An object rather than a bool on purpose: the submit route truncates instead of rejecting (a rejected report is a lost report), so the *only* way an operator can tell a genuinely short report from one that dropped 412 console lines is a count. Same posture as `neo issue context`'s `truncated` field — say what you bounded instead of capping silently. `Default` is "nothing was bounded", which is also the value a legacy or unparsable `truncated` column degrades to (see `PgFeedbackStore`).

body_truncated

boolean

The body was longer than the cap and was cut.

console_dropped

integerint32

Console entries dropped to fit the per-report caps (oldest first — the newest lines are the ones near the bug).

requests_dropped

integerint32

Request entries dropped, same direction.

403Forbidden — one of: forbidden, needs_scope

application/json

one of
  • ErrorForbidden
  • NeedsScopeError

    object

    `403` body returned when listing private repos but the linked identity lacks the required provider scope. The SPA turns this into an incremental-authorization prompt (Tier 2) that calls the `/elevate` OAuth endpoint with this `scope`.

    errorrequired

    string

    Always `needs_scope` — this body exists to carry the extra fields that kind needs.

    "needs_scope"

    scoperequired

    string

    The provider scope to request via elevation (e.g. `"repo"`).

413The screenshot decodes to more than the server's per-image limit, NORMALISES to more than that limit, or the whole request body exceeded the route's limit. Nothing else on this route is size-rejected — it is truncated.

415The screenshot's content type is not an allowlisted raster image. SVG is deliberately never accepted.

503The process-global upload pool is full. Held for the whole handler, ahead of the image decode, so a flood is shed here rather than queueing on a decode permit while holding a buffered body. Retry after the `Retry-After` header.

Standard errors

Bodies documented once for the whole API — see standard errors.

  • 400Bad Request — one of: invalid_input
  • 401Authentication required
  • 404Not Found — one of: not_found
  • 429Rate limited — retry after the `Retry-After` header
  • 500Internal server error
  • 504Gateway timeout — the request exceeded the server's handling budget