Accept a report, so blocking is not the only thing a person can do

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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-02 19:58:28 -04:00
co-authored by Claude Opus 4.8
parent 58e2d980c6
commit 9bab0874f9
4 changed files with 307 additions and 1 deletions
+10
View File
@@ -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"))
+5 -1
View File
@@ -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()]
}
}
@@ -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<String> = [
"abuse", "harassment", "spam", "impersonation", "csam", "other",
]
static let kinds: Set<String> = ["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()
}
}
@@ -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)
}
}
}