From 9bab0874f96fab3ee055e7b8ece8310e462483f0 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Wed, 2 Sep 2026 10:44:52 -0400 Subject: [PATCH 1/2] Accept a report, so blocking is not the only thing a person can do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There was no report mechanism anywhere — not in either app, not here. `Moderation.swift` held blocks and notifications and nothing else, which is why "report or block from a story" could not be built as one thing. 🔴 A report is the one place a user deliberately breaks their own confidentiality, and the row reflects that. The relay stores ciphertext it cannot open, so it cannot examine a reported story on its own account; a report carrying only an accusation would give an operator nothing but the assertion, which makes moderation either credulous or useless. So the reporter's own decrypted copy travels with it, and §6.25 requires the client to say so in those words before sending. ⚠️ There is deliberately NO route that reads reports back. 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. The test asserts that against the router rather than by making a request, so adding one has to break it deliberately. ⚠️ An empty copy is accepted rather than refused. Refusing would mean the only way to report something is to hand over a copy of it, which is a worse bargain than the one §6.25 makes; the report is weaker, not invalid. `content` is `text`, not a varchar: a caption plus a note can exceed a driver's default and a report truncated by the schema is evidence quietly thrown away. It is bounded in the route instead, where the limit is visible. Three of these tests first passed for the wrong reason — the signing path was `/moderation/report` where the router mounts `/e2e/moderation/report`, so `authenticate` threw `unauthorized`, which is a `WireError` too and satisfied `#expect(throws: WireError.self)`. They assert the error code now. Co-Authored-By: Claude Opus 4.8 --- Sources/FlamenetRelay/Entrypoint.swift | 10 ++ Sources/FlamenetRelay/Migrations.swift | 6 +- Sources/FlamenetRelay/RoutesModeration.swift | 136 +++++++++++++++ .../ContentReportTests.swift | 156 ++++++++++++++++++ 4 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 Sources/FlamenetRelay/RoutesModeration.swift create mode 100644 Tests/FlamenetRelayTests/ContentReportTests.swift diff --git a/Sources/FlamenetRelay/Entrypoint.swift b/Sources/FlamenetRelay/Entrypoint.swift index 06b7421..b571008 100644 --- a/Sources/FlamenetRelay/Entrypoint.swift +++ b/Sources/FlamenetRelay/Entrypoint.swift @@ -926,6 +926,16 @@ func register(routes r: Routes, on app: Application) throws { e2e.get("turn") { req in try await r.turnCredentials(req, now: unixNow()) } + + // §6.25 — file a report. + // + // ⚠️ 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. + e2e.post("moderation", "report") { req in + try await r.submitReport(req, now: unixNow()) + } } mountE2E(app.grouped("e2e")) diff --git a/Sources/FlamenetRelay/Migrations.swift b/Sources/FlamenetRelay/Migrations.swift index 528b492..c26c989 100644 --- a/Sources/FlamenetRelay/Migrations.swift +++ b/Sources/FlamenetRelay/Migrations.swift @@ -608,6 +608,10 @@ enum RelayMigrations { AddAccountIdentity(), AddLogSubject(), CreateHomeRecord(), AddEnvelopeID(), AddDeviceLastSeen(), AddVoipToken(), AcceptExistingContacts(), BackfillKeyLog(), CreateDeliveryToken(), DropGroupNames(), - SealGroupRosters(), CreateDeviceWipeOrder()] + SealGroupRosters(), CreateDeviceWipeOrder(), + // ⚠️ Appended, never inserted. Fluent runs these in registration + // order and records what it has run; putting a new one in the middle + // changes the order for a database that has already migrated. + CreateContentReport()] } } diff --git a/Sources/FlamenetRelay/RoutesModeration.swift b/Sources/FlamenetRelay/RoutesModeration.swift new file mode 100644 index 0000000..447dbd1 --- /dev/null +++ b/Sources/FlamenetRelay/RoutesModeration.swift @@ -0,0 +1,136 @@ +import Fluent +import Foundation +import SQLKit +import Vapor + +/// A report the operator can actually act on (spec §6.25). +/// +/// 🔴 **This row is the one place the operator can read user content, and it +/// is here because a reporter chose to put it here.** The relay stores +/// ciphertext it cannot open, so it cannot examine a reported story on its own +/// account. A report carrying only an accusation would give an operator nothing +/// but the assertion, which makes moderation either credulous or useless — so +/// the reporter's own decrypted copy travels with it, and the client is +/// required to say so before sending (§6.25). +/// +/// ⚠️ **Not exposed on any read route.** There is deliberately no endpoint that +/// returns these. Reports are read out of band by whoever operates the server; +/// a route would be a way to ask the relay what somebody reported, which is a +/// question nobody but the operator gets to ask. +final class ContentReport: Model, @unchecked Sendable { + static let schema = "content_reports" + + @ID(custom: "id", generatedBy: .database) var id: Int? + /// Who filed it. + @Field(key: "reporter_id") var reporterID: Int + /// Who it is about. + @Field(key: "about_id") var aboutID: Int + /// `story` or `message` — which surface it came from. + @Field(key: "kind") var kind: String + /// A short enumerated reason. + @Field(key: "reason") var reason: String + /// The reporter's decrypted copy. May be empty: a reporter may decline to + /// include it, and a report without it is weaker rather than invalid. + @Field(key: "content") var content: String + /// The reporter's own words, if they added any. + @Field(key: "note") var note: String + @Field(key: "created_at") var createdAt: Int + + init() {} + + /// The reasons a client may send. Enumerated rather than free text so the + /// column stays sortable and a client cannot smuggle a third field in here. + static let reasons: Set = [ + "abuse", "harassment", "spam", "impersonation", "csam", "other", + ] + static let kinds: Set = ["story", "message"] + + /// The largest copy a client may attach. + /// + /// ⚠️ A bound, because this is unencrypted user text on the operator's disk + /// and the route is authenticated but otherwise open. Large enough for a + /// caption and a note, far too small to use as storage. + static let maximumContent = 8_192 +} + +extension Routes { + + /// §6.25 — file a report. + /// + /// ⚠️ The reported account is never told, here or anywhere else. A report + /// 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 { + let body = request.body.data.map { Data(buffer: $0) } ?? Data() + let caller = try await authenticate( + request, body: body, path: "/e2e/moderation/report", now: now) + + struct Submission: Content { + let about: Int + let kind: String + let reason: String + let content: String? + let note: String? + } + let submission = try request.content.decode(Submission.self) + + guard ContentReport.kinds.contains(submission.kind) else { + throw WireError("bad_kind", "Unknown report kind.", 400) + } + guard ContentReport.reasons.contains(submission.reason) else { + throw WireError("bad_reason", "Unknown report reason.", 400) + } + // ⚠️ Reporting yourself is not a thing, and allowing it would let an + // account fill the table without another party being involved. + guard submission.about != caller.userID else { + throw WireError("bad_subject", "You cannot report yourself.", 400) + } + let content = String((submission.content ?? "").prefix(ContentReport.maximumContent)) + let note = String((submission.note ?? "").prefix(ContentReport.maximumContent)) + + let report = ContentReport() + report.reporterID = caller.userID + report.aboutID = submission.about + report.kind = submission.kind + report.reason = submission.reason + report.content = content + report.note = note + report.createdAt = now + try await report.save(on: request.db) + + // Nothing about the outcome is promised. A report is a request to + // somebody else, and telling the reporter "we have actioned this" when + // nobody has looked at it yet would be a lie with a nice tone. + return try .json(["ok": true]) + } +} + +/// ⚠️ `content` is `.sql(raw: "text")` rather than `.string`: a caption plus a +/// note can exceed whatever a driver picks for a default varchar, and a report +/// truncated by the schema is evidence quietly thrown away. +struct CreateContentReport: AsyncMigration { + func prepare(on database: any Database) async throws { + try await database.schema(ContentReport.schema) + .field("id", .int, .identifier(auto: true)) + .field("reporter_id", .int, .required) + .field("about_id", .int, .required) + .field("kind", .string, .required) + .field("reason", .string, .required) + .field("content", .sql(raw: "text"), .required) + .field("note", .sql(raw: "text"), .required) + .field("created_at", .int, .required) + .create() + // An operator's question is "what has been reported about this + // account", so that is what gets the index. + if let sql = database as? any SQLDatabase { + try? await sql.raw(""" + CREATE INDEX IF NOT EXISTS content_reports_about + ON content_reports (about_id) + """).run() + } + } + + func revert(on database: any Database) async throws { + try await database.schema(ContentReport.schema).delete() + } +} diff --git a/Tests/FlamenetRelayTests/ContentReportTests.swift b/Tests/FlamenetRelayTests/ContentReportTests.swift new file mode 100644 index 0000000..59f7d33 --- /dev/null +++ b/Tests/FlamenetRelayTests/ContentReportTests.swift @@ -0,0 +1,156 @@ +import Crypto +import Fluent +import Foundation +import Testing +import Vapor + +@testable import FlamenetRelay + +/// Reporting content (spec §6.25). +/// +/// 🔴 **A report is the one place the operator can read user content, and it is +/// readable because the reporter chose to send it.** The relay holds ciphertext +/// it cannot open, so a report carrying only an accusation would give an +/// operator nothing but the assertion. Everything here is about that row being +/// exactly what the reporter meant to file, and nothing more. +@Suite("content reports") +struct ContentReportTests { + + private let now = 1_700_000_000 + + private func body(about: Int, kind: String = "story", reason: String = "abuse", + content: String = "what they posted", note: String = "") -> Data { + Data(""" + {"about":\(about),"kind":"\(kind)","reason":"\(reason)", + "content":"\(content)","note":"\(note)"} + """.utf8) + } + + private func file(_ h: RelayHarness, as d: RelayHarness.TestDevice, + _ payload: Data) async throws -> Response { + let req = h.request(.POST, "/e2e/moderation/report", as: d, body: payload, now: now) + return try await h.routes.submitReport(req, now: now) + } + + @Test("a report is stored with the reporter's copy") + func aReportIsStored() async throws { + try await withRelay { h in + let d = h.device(user: 1, id: "aaaa-0001") + _ = try await h.register(d, on: h.db) + let response = try await file(h, as: d, body(about: 2)) + #expect(response.status == .ok) + + let stored = try await ContentReport.query(on: h.db).all() + #expect(stored.count == 1) + #expect(stored[0].reporterID == 1) + #expect(stored[0].aboutID == 2) + #expect(stored[0].content == "what they posted") + } + } + + /// ⚠️ A reporter may decline to include the content. The report is weaker, + /// not invalid — refusing it would mean the only way to report something is + /// to hand over a copy, which is a worse bargain than the one §6.25 makes. + @Test("a report with no content is still accepted") + func anEmptyReportIsAccepted() async throws { + try await withRelay { h in + let d = h.device(user: 1, id: "aaaa-0001") + _ = try await h.register(d, on: h.db) + let response = try await file(h, as: d, body(about: 2, content: "")) + #expect(response.status == .ok) + #expect(try await ContentReport.query(on: h.db).count() == 1) + } + } + + /// ⚠️ Enumerated, so the column stays sortable and a client cannot smuggle + /// a third field through the reason. + @Test("an unknown reason is refused") + func anUnknownReasonIsRefused() async throws { + try await withRelay { h in + let d = h.device(user: 1, id: "aaaa-0001") + _ = try await h.register(d, on: h.db) + // ⚠️ Asserting the CODE, not merely that something threw. With the + // signing path wrong these tests passed on `unauthorized`, which + // is a WireError too — a green test proving nothing about reasons. + var code = "" + do { _ = try await file(h, as: d, body(about: 2, reason: "whatever")) } + catch let e as WireError { code = e.code } + #expect(code == "bad_reason") + #expect(try await ContentReport.query(on: h.db).count() == 0) + } + } + + @Test("an unknown kind is refused") + func anUnknownKindIsRefused() async throws { + try await withRelay { h in + let d = h.device(user: 1, id: "aaaa-0001") + _ = try await h.register(d, on: h.db) + var code = "" + do { _ = try await file(h, as: d, body(about: 2, kind: "diary")) } + catch let e as WireError { code = e.code } + #expect(code == "bad_kind") + } + } + + /// ⚠️ Reporting yourself would let one account fill the table without any + /// other party being involved. + @Test("reporting yourself is refused") + func reportingYourselfIsRefused() async throws { + try await withRelay { h in + let d = h.device(user: 1, id: "aaaa-0001") + _ = try await h.register(d, on: h.db) + var code = "" + do { _ = try await file(h, as: d, body(about: 1)) } + catch let e as WireError { code = e.code } + #expect(code == "bad_subject") + } + } + + /// 🔴 Unauthenticated reporting would let anybody fill an operator's table + /// with content attributed to accounts they do not hold. + @Test("an unauthenticated report is refused") + func anUnauthenticatedReportIsRefused() async throws { + try await withRelay { h in + let payload = body(about: 2) + let req = h.anonymousRequest(.POST, "/e2e/moderation/report", body: payload) + await #expect(throws: Error.self) { + _ = try await h.routes.submitReport(req, now: now) + } + } + } + + /// ⚠️ Bounded: this is unencrypted user text on the operator's disk, and + /// the route is authenticated but otherwise open. + @Test("an oversized copy is truncated rather than refused") + func anOversizedCopyIsTruncated() async throws { + try await withRelay { h in + let d = h.device(user: 1, id: "aaaa-0001") + _ = try await h.register(d, on: h.db) + let huge = String(repeating: "a", count: ContentReport.maximumContent + 500) + _ = try await file(h, as: d, body(about: 2, content: huge)) + let stored = try await ContentReport.query(on: h.db).all() + #expect(stored[0].content.count == ContentReport.maximumContent) + } + } + + /// 🔴 **No read route exists, and that is deliberate.** An endpoint + /// returning reports would be a way to ask the relay what somebody + /// reported — a question nobody but the operator gets to ask. This test + /// exists so that adding one is a conscious act that breaks it. + @Test("nothing on the wire can read reports back") + func reportsAreNotReadable() async throws { + try await withRelay { h in + let d = h.device(user: 1, id: "aaaa-0001") + _ = try await h.register(d, on: h.db) + _ = try await file(h, as: d, body(about: 2)) + // Asserted against the ROUTER rather than by making a request: the + // point is that no route returning reports exists at all, so adding + // one has to break this deliberately. + let readRoutes = h.app.routes.all.filter { + $0.path.map(\.description).joined(separator: "/").contains("moderation") + && $0.method != .POST + } + #expect(readRoutes.isEmpty) + } + } +} -- 2.54.0 From 2cc0a80abe62c7ab5b2aabb9eb9b2d5378705d5d Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Wed, 2 Sep 2026 15:37:58 -0400 Subject: [PATCH 2/2] Give reports a switch, and say in discovery which way it is set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Resources/Views/configuration.leaf | 37 +++++++++- Sources/FlamenetRelay/Config.swift | 16 +++++ Sources/FlamenetRelay/RoutesDiscovery.swift | 10 ++- Sources/FlamenetRelay/RoutesModeration.swift | 10 +++ .../ContentReportTests.swift | 70 +++++++++++++++++++ docs/CONFIGURATION.md | 13 ++++ 6 files changed, 154 insertions(+), 2 deletions(-) diff --git a/Resources/Views/configuration.leaf b/Resources/Views/configuration.leaf index 78a7949..649d248 100644 --- a/Resources/Views/configuration.leaf +++ b/Resources/Views/configuration.leaf @@ -3,7 +3,7 @@ + source-sha256: e592ce3fe1f1b4fe -->

Configuring a relay

@@ -375,6 +375,41 @@ 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.

+ + + + + + + + + + + + + + + +
VariableDefaultWhat it does
FN_CONTENT_REPORTSonSet 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

The relay purges on its own timer, in-process. Nothing external is needed.

diff --git a/Sources/FlamenetRelay/Config.swift b/Sources/FlamenetRelay/Config.swift index e169a2f..dad78c4 100644 --- a/Sources/FlamenetRelay/Config.swift +++ b/Sources/FlamenetRelay/Config.swift @@ -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), diff --git a/Sources/FlamenetRelay/RoutesDiscovery.swift b/Sources/FlamenetRelay/RoutesDiscovery.swift index bc0062a..9192380 100644 --- a/Sources/FlamenetRelay/RoutesDiscovery.swift +++ b/Sources/FlamenetRelay/RoutesDiscovery.swift @@ -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 )) } diff --git a/Sources/FlamenetRelay/RoutesModeration.swift b/Sources/FlamenetRelay/RoutesModeration.swift index 447dbd1..7d29b68 100644 --- a/Sources/FlamenetRelay/RoutesModeration.swift +++ b/Sources/FlamenetRelay/RoutesModeration.swift @@ -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) diff --git a/Tests/FlamenetRelayTests/ContentReportTests.swift b/Tests/FlamenetRelayTests/ContentReportTests.swift index 59f7d33..df542d7 100644 --- a/Tests/FlamenetRelayTests/ContentReportTests.swift +++ b/Tests/FlamenetRelayTests/ContentReportTests.swift @@ -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") + } + } } diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index be14639..48281ba 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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 -- 2.54.0