# Showing a player their replies, and letting them answer

The contract for a game that wants a report to be a conversation rather than a letter posted into
a hole: the player sees what was written back, and can answer it or close the thread from inside
the game.

Filing the report in the first place is [sending](sending). This document is the half that comes
afterwards, and it is a different job with a different rule attached.

Base address: `https://dasmaffin.com`.

---

## Which games can use this

**Every game that takes feedback**, at its own address. These moved out of TGSC's module and into
the reports integration on 7 September 2026; the addresses did not change, because
`/{module}/api/me/reports` is `/Tgsc/api/me/reports` for TGSC, which is where it already was.

What a game needs is a way to answer **"who is asking"**, and there are exactly two:

| Your game | Mode | Who is proven, and how |
| --- | --- | --- |
| Has no server of its own | Public key | The client proves the player. TGSC does this with a Steam ticket |
| Has a server | Private key | The server holds the key and names the player; it is believed |

A game with neither answers `404` on every route here. That is deliberate and it is the whole
security model: see [Who is asking](#who-is-asking) at the end.

---

## The rule: reading is not sending

Sending a report trusts whatever sender id is in the form. It can afford to: the worst somebody
achieves by lying is filing a report under another player's name, and a report is a thing they
wrote themselves.

Reading one back cannot afford it. A report carries a screenshot of whatever was on that player's
screen and the log of their session. An endpoint that hands those to anybody who can type a player
id is a leak with a query string. So every route here establishes who is asking before it answers,
by one of the two means in the table above, and never from a player id in the request body alone.

That is also why **every route is a POST, including the ones that only read**. A ticket and a
private key are both credentials, and credentials do not belong in a URL, where they land in the
server's access log and in every proxy in between.

---

## The four routes

```
POST /{module}/api/me/reports                  what I have sent
POST /{module}/api/me/reports/{id}             one of them, with the conversation
POST /{module}/api/me/reports/{id}/comment     answer it
POST /{module}/api/me/reports/{id}/close       close it
Content-Type: application/json
X-Api-Key: <this game's key, when it has one>
```

`{module}` is `Tgsc` or `TouchGrass`, the same segment you post a report to.

Every one takes the same body, and only ever needs the fields that route uses:

```json
{ "ticket": "5410...hex...", "body": "Still happens on build 41." }
```

| Field | Used by | Meaning |
| --- | --- | --- |
| `ticket` | public-key games | What vouches for the player. For TGSC, a Steam auth ticket as hex |
| `player` | private-key games | Which player this is about. **Read in this mode and no other** |
| `body` | `/comment` | What the player wrote. Anything empty is refused |

A public-key game never sends the player id: it is what the ticket resolves to, which is the entire
point, because a value the game supplies is a claim and a value Steam returns is an answer. A
private-key game does send it, and is believed, because the only thing holding that key is a server
you run.

Sending `player` to a game that is not in private-key mode does nothing at all. It is not merely
discouraged - it is never read, because a field that is trusted sometimes is a field somebody
eventually trusts always.

### What I have sent

`POST /{module}/api/me/reports`

```json
{
  "unread": 2,
  "reports": [
    {
      "id": 412,
      "kind": "Bug",
      "summary": "Garden resets after closing the app",
      "receivedAt": "2026-09-02T19:04:11.318204",
      "open": true,
      "unread": 1,
      "messages": 3
    }
  ]
}
```

Newest first. The top-level `unread` is the sum across every report, which is what a badge on a
menu button wants: ask once, not once per report.

### One of them

`POST /{module}/api/me/reports/{id}`

```json
{
  "id": 412,
  "kind": "Bug",
  "summary": "Garden resets after closing the app",
  "description": "Planted three plots, closed the app, came back and it was empty.",
  "receivedAt": "2026-09-02T19:04:11.318204",
  "editedAt": null,
  "closedAt": null,
  "open": true,
  "messages": [
    { "fromStaff": false, "body": "Still happens on build 41.", "writtenAt": "2026-09-03T08:00:00.114900" },
    { "fromStaff": true,  "body": "Which device is this on?",   "writtenAt": "2026-09-03T09:12:00.902551" }
  ]
}
```

**Every time on this API is UTC and says so with nothing.** They are ISO 8601 with sub-second
precision and **no `Z` and no offset** - `2026-09-02T19:04:11.318204`. Parse them as UTC
explicitly. A parser left to guess will call them local time, which on a phone in Warsaw is two
hours of drift in a timestamp the player reads back as when they sent it.

```csharp
DateTime.Parse(t, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal)
```

`messages` is the whole conversation, oldest first, both sides. `fromStaff` is the only thing that
says who wrote which, so it is what decides the side of the screen a line is drawn on.

**Opening a report is reading it.** This route clears that report's unread count as a side effect,
which is why it is the one to call when the player actually looks. Listing does not clear anything,
and posting a comment does not either, or answering a thread would mark the reply nobody has read
as read.

**Text only.** Nothing here hands back a file: no screenshot, no log, no save. The game shows the
conversation and offers a button to the site for everything else, which is one less way for a leak
to be a large one.

### Answer it

`POST /{module}/api/me/reports/{id}/comment` with `body`. Answers `{ "ok": true }`.

### Close it

`POST /{module}/api/me/reports/{id}/close`. Answers `{ "ok": true }`.

Either side can close a thread, and closing starts the 28 day clock after which the report and its
files are deleted, sooner than the 120 days an open one gets. A closed report cannot be commented
on by either side.

### What comes back

| Code | Meaning |
| --- | --- |
| `200` | Done |
| `401` | The proof failed: a missing or wrong `X-Api-Key`, or a ticket that was expired, malformed or not for this game. Body says `no_key` or `bad_key` when it was the key |
| `404` | No such report, **or it is not this player's**, or this game has no way to establish who is asking |
| `409` | `{"ok":false,"why":"closed"}` - the thread is closed, so nothing can be added |
| `400` | `{"ok":false,"why":"empty"}` - the comment had nothing in it |

**A report belonging to somebody else answers `404`, not `403`.** Whether a report exists is not
something to confirm to somebody who cannot see it, so "not yours" and "not there" are one answer.
Do not write a client that tells them apart, because it cannot.

`404` also covers "this game cannot establish who is asking" - it is open with no prover configured,
or TGSC without a Steam publisher key. A client that reads `404` on the list route as "no reports"
will show an empty inbox on a site that simply could not answer, so treat it as an error on that
route rather than as an empty one.

---

## The ticket

Steam's own call, in the game:

```csharp
// Unity, Steamworks.NET
var buffer = new byte[1024];
SteamUser.GetAuthSessionTicket(buffer, buffer.Length, out uint written, ref identity);
string ticket = BitConverter.ToString(buffer, 0, (int)written).Replace("-", string.Empty);
```

The site hands that to Steam's `ISteamUserAuth/AuthenticateUserTicket` with the game's App ID and
uses the SteamID that comes back. A ticket is short-lived and single-purpose: get a fresh one for a
session rather than storing one, and let it be rejected rather than trying to predict when it has
expired.

---

## Who is asking

Everything behind these routes is shared. The conversation, the ownership check, the unread count,
closing, the retention clock: all of it takes the module as a parameter, exactly as sending a report
does. The only thing that was ever game-specific is the answer to "who is asking", and there is no
generic answer to that, because it depends on what your game has.

**With a server (private key).** The request reaching this site comes from a machine you run, which
holds a key no player has. It names the player in `player` and is believed. This is the strongest
option and the least work: your server already knows who its users are.

**Without a server (public key).** The only thing that can call this site is the player's own copy of
the game, and anything that copy holds, the player holds. So a public key identifies the *build*,
not the player - it is worth having for attribution, rate limiting and being able to turn one
version off, and it is worth nothing as a secret. The player themselves still has to be proven, and
TGSC does that with a Steam ticket: the game asks Steam for one, this site asks Steam whose it is,
and neither side has to take the other's word.

**With neither, there are no routes.** A game that is open and identifies players by an id its own
client supplies gets `404` here, on purpose. Answering would hand anybody holding a player id that
player's screenshots and session logs, and player ids are not secret: they sit in the game's own
logs and on every report. Turning the feature on for such a game is not a setting, it is bringing
something that vouches.

Touch Grass is that case today. It has a server, so private-key mode is the short path: point the
server at these routes with the key from
`/TouchGrass/Reports/Settings` and it works without the game changing at all. Unity Authentication
would be the other way, if the phone is ever to call this site directly - it issues a signed token
for the same anonymous player id reports are already filed under, and that token can be verified
against Unity's published keys.
