Give reports a switch, and say in discovery which way it is set

FN_CONTENT_REPORTS, on unless explicitly set to false/0/no/off. On by
default because without it blocking is the only thing somebody being
harassed can do, which is the state this feature exists to leave.

Off is a real answer, not a broken relay: a self-hoster with nobody to read
reports should be able to say so, rather than collect a queue nobody opens.

Two things make that honest rather than silent:

- `/.well-known/flamenet/relay` now carries `reports`, so a client can
  leave the Report option out of the interface instead of offering a button
  that fails. Additive and safe for the build in the field — v8 parses the
  descriptor with JSONObject.optString and ignores keys it does not know;
  checked rather than assumed. An older relay omits the key, and reading
  absent as yes is correct: every relay predating the switch takes reports.
- The route refuses with `reports_not_accepted` (501), never a 404, so a
  client can tell "does not take reports" from "too old to know what a
  report is". One is a setting; the other is a reason to stop asking.

Tests: on with nothing configured; off refuses with the code and writes
nothing; discovery advertises both states; and only an explicit no disables
it, so a typo cannot quietly stop a relay taking reports. 482 pass.
This commit is contained in:
Paul Hitt
2026-09-02 20:00:34 -04:00
parent 9bab0874f9
commit 2cc0a80abe
6 changed files with 154 additions and 2 deletions
+36 -1
View File
@@ -3,7 +3,7 @@
<!-- GENERATED by Scripts/build-doc-pages.sh from docs/CONFIGURATION.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it.
source-sha256: 41e0e20cc4e1f529 -->
source-sha256: e592ce3fe1f1b4fe -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="configuring-a-relay">Configuring a relay</h1>
@@ -375,6 +375,41 @@ withholding one stops a wipe rather than causing one. When off, both
routes refuse with <code>wipe_not_enabled</code> rather than a 404, so a
client can tell a server that has this switched off from one too old to
know what it is.</p>
<h2 id="reports-about-content">Reports about content</h2>
<p>On. A person can report something to whoever runs the server, so
blocking is not the only thing they can do.</p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Default</th>
<th>What it does</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>FN_CONTENT_REPORTS</code></td>
<td>on</td>
<td>Set to <code>false</code>, <code>0</code>, <code>no</code> or
<code>off</code> to stop taking reports. Anything else, including a
typo, leaves them on.</td>
</tr>
</tbody>
</table>
<p>Off is a legitimate answer, not a broken relay. If nobody is going to
read them, collecting reports is a promise the server cannot keep, and
saying so plainly is better than a queue nobody opens.</p>
<p>When off, two things happen so that nothing pretends otherwise:
<code>/.well-known/flamenet/relay</code> reports
<code>"reports": false</code>, which lets a client leave the Report
option out of the interface entirely; and the route itself refuses with
<code>reports_not_accepted</code> rather than a 404, so a client can
tell "this server does not take reports" apart from "this server is too
old to know what a report is".</p>
<p>There is deliberately no route that <strong>reads</strong> reports.
They are read out of band by whoever operates the server — an endpoint
returning them would be a way to ask the relay what somebody reported,
which is a question nobody but the operator gets to ask.</p>
<h2 id="retention">Retention</h2>
<p>The relay purges on its own timer, in-process. Nothing external is
needed.</p>
+16
View File
@@ -147,6 +147,17 @@ struct Config: Sendable {
/// carrying it is small. The reason to keep it off is honesty about what
/// works, not fear of the route.
let deviceWipeEnabled: Bool
/// Whether this relay accepts reports about content (§6.25).
///
/// **On by default.** Without it, blocking is the only thing a person being
/// harassed can do, and that is the state this feature exists to leave.
///
/// ⚠️ Off is a real answer, not a broken one. A self-hoster with nobody to
/// read reports should be able to say so — collecting reports that will
/// never be looked at is a promise the relay cannot keep. When off, the
/// discovery document says so, so clients can leave the affordance out
/// rather than offer a button that fails.
let contentReportsEnabled: Bool
let attachmentBytesPerHour: Int
/// The rolling 30-day per-account upload budget (C3). The hourly limits
/// above stop bursts; this stops a steady drip from quietly owning the
@@ -284,6 +295,11 @@ struct Config: Sendable {
let raw = (env["FN_DEVICE_WIPE"] ?? "").lowercased()
return raw == "true" || raw == "1" || raw == "yes"
}(),
contentReportsEnabled: {
// On unless explicitly switched off.
let raw = (env["FN_CONTENT_REPORTS"] ?? "").lowercased()
return !(raw == "false" || raw == "0" || raw == "no" || raw == "off")
}(),
attachmentBytesPerHour: int("FN_ATTACHMENT_BYTES_RATE", 200 * 1024 * 1024),
attachmentBytesPerMonth: int("FN_ATTACHMENT_BYTES_PER_MONTH", 500 * 1024 * 1024),
attachmentRetentionDays: int("FN_ATTACHMENT_RETENTION_DAYS", 30),
+9 -1
View File
@@ -70,6 +70,13 @@ extension Routes {
let accounts: String
/// Absolute URL of that account service, when there is one.
let accountsURL: String?
/// Whether this relay takes reports about content (§6.25).
///
/// Advertised so a client can leave the Report affordance out entirely
/// rather than offer a button that fails. An older relay omits the key,
/// and a client reading it as absent-means-yes is right: every relay
/// that predates the switch does accept reports.
let reports: Bool
}
/// `GET /.well-known/flamenet/relay`
@@ -91,7 +98,8 @@ extension Routes {
: (try? Curve25519.Signing.PrivateKey(rawRepresentation: config.federationKeyRaw ?? Data()))
.map { $0.publicKey.rawRepresentation.base64EncodedString() },
accounts: shared ? "shared" : "local",
accountsURL: shared ? Self.accountsURL : nil
accountsURL: shared ? Self.accountsURL : nil,
reports: config.contentReportsEnabled
))
}
@@ -61,6 +61,16 @@ extension Routes {
/// that told its subject who filed it would be most dangerous to exactly
/// the people most likely to need it.
func submitReport(_ request: Request, now: Int) async throws -> Response {
// ⚠️ Refused with a code, not a 404. A relay that has switched reports
// off is a working relay that does not take them, and a client needs to
// tell that apart from an old relay that has never heard of the route —
// one is a setting, the other is a reason to stop asking. The
// discovery document carries the same fact, so a client that reads it
// never has to find out this way.
guard config.contentReportsEnabled else {
throw WireError("reports_not_accepted",
"Whoever runs this server does not take reports.", 501)
}
let body = request.body.data.map { Data(buffer: $0) } ?? Data()
let caller = try await authenticate(
request, body: body, path: "/e2e/moderation/report", now: now)
@@ -153,4 +153,74 @@ struct ContentReportTests {
#expect(readRoutes.isEmpty)
}
}
// MARK: - The switch
/// On by default: an operator who has configured nothing still takes
/// reports, because the alternative leaves blocking as the only recourse.
@Test("reports are accepted with nothing configured")
func onByDefault() async throws {
try await withRelay { h in
let d = h.device(user: 1, id: "aaaa-0001")
_ = try await h.register(d, on: h.db)
#expect(try await file(h, as: d, body(about: 2)).status == .ok)
}
}
/// 🔴 Off is refused with a code, not a 404.
///
/// A relay that has switched reports off is a working relay that does not
/// take them. A client has to tell that apart from an old relay that never
/// had the route: one is a setting, the other is a reason to stop asking.
@Test("switched off, a report is refused in a way a client can read")
func offRefusesWithACode() async throws {
try await withRelay(env: ["FN_CONTENT_REPORTS": "false"]) { h in
let d = h.device(user: 1, id: "aaaa-0001")
_ = try await h.register(d, on: h.db)
do {
_ = try await self.file(h, as: d, self.body(about: 2))
Issue.record("a report was accepted on a relay that does not take them")
} catch let error as WireError {
#expect(error.code == "reports_not_accepted")
#expect(error.data.status == 501)
}
// And nothing was written.
#expect(try await ContentReport.query(on: h.db).count() == 0)
}
}
/// The discovery document carries the same fact, so a client can leave the
/// affordance out instead of finding out by failing.
@Test("the relay says in discovery whether it takes reports")
func discoveryAdvertisesIt() async throws {
try await withRelay { h in
let on = try await h.routes.relayDescriptor(h.anonymousRequest(.GET, "/.well-known/flamenet/relay"))
#expect(String(buffer: on.body.buffer!).contains("\"reports\":true"))
}
try await withRelay(env: ["FN_CONTENT_REPORTS": "false"]) { h in
let off = try await h.routes.relayDescriptor(h.anonymousRequest(.GET, "/.well-known/flamenet/relay"))
#expect(String(buffer: off.body.buffer!).contains("\"reports\":false"))
}
}
/// Only an explicit no turns it off — a typo must not silently stop the
/// relay taking reports.
@Test("only an explicit no switches reports off")
func onlyExplicitNoDisables() throws {
func flag(_ v: String?) throws -> Bool {
var env = [
"FN_ISSUER_PUBLIC_KEY": Curve25519.Signing.PrivateKey()
.publicKey.rawRepresentation.base64EncodedString(),
"FN_RELAY_AUDIENCE": "test.relay",
]
if let v { env["FN_CONTENT_REPORTS"] = v }
return try Config.fromEnvironment(env).contentReportsEnabled
}
for on in [nil, "", "true", "yes", "1", "flase", "disabled"] {
#expect(try flag(on) == true, "\(on ?? "unset") should leave reports on")
}
for off in ["false", "FALSE", "0", "no", "off"] {
#expect(try flag(off) == false, "\(off) should switch reports off")
}
}
}
+13
View File
@@ -141,6 +141,19 @@ Off. The relay does not carry instructions for a device to erase itself.
Off because the feature is not finished end to end, not because the route is dangerous. No client in the field can honour a wipe — the Android build released on 2026-08-31 (v8) never asks for one — and the issuing side still needs the passphrase re-entered before an order can be signed. A half-built remote erase that is on by default looks like a guarantee and is not one yet.
The relay is only ever a courier. The instruction is signed by the account key, which the relay does not hold, so it cannot forge one; and withholding one stops a wipe rather than causing one. When off, both routes refuse with `wipe_not_enabled` rather than a 404, so a client can tell a server that has this switched off from one too old to know what it is.
## Reports about content
On. A person can report something to whoever runs the server, so blocking is not the only thing they can do.
| Variable | Default | What it does |
|---|---|---|
| `FN_CONTENT_REPORTS` | on | Set to `false`, `0`, `no` or `off` to stop taking reports. Anything else, including a typo, leaves them on. |
Off is a legitimate answer, not a broken relay. If nobody is going to read them, collecting reports is a promise the server cannot keep, and saying so plainly is better than a queue nobody opens.
When off, two things happen so that nothing pretends otherwise: `/.well-known/flamenet/relay` reports `"reports": false`, which lets a client leave the Report option out of the interface entirely; and the route itself refuses with `reports_not_accepted` rather than a 404, so a client can tell "this server does not take reports" apart from "this server is too old to know what a report is".
There is deliberately no route that **reads** reports. They are read out of band by whoever operates the server — an endpoint returning them would be a way to ask the relay what somebody reported, which is a question nobody but the operator gets to ask.
## Retention