Member-vouched sign-up codes, wired in and switched off

A new account can be made to require a code from an existing member,
which ties it to somebody who already has one: bulk sign-ups get costly
and every account gains a provenance.

🔴 It is off, and it is inert without an end date. Vouching also slows
genuine growth and creates an in-group, which is a reasonable trade for a
phase and a bad one for a policy — so `FN_VOUCH_REQUIRED` alone does
nothing and the gate only engages with `FN_VOUCH_UNTIL` set. It then
turns itself off when that passes, because a phase whose end depends on
somebody remembering to change a variable is a policy with extra steps.
Failing open is the right direction: an invite-only server nobody can
join is worse than an open one.

🔴 The tests caught a real bug in my own first cut. Spentness was read
off `usedBy`, which is filled in only once the new account exists — so a
redeemed code still looked unspent and could be spent again without
limit, which is the entire mechanism defeated. A sign-up that failed
after redeeming also left a code unspent for ever. `usedAt` is the spent
flag now; `usedBy` is provenance, attached to the row redemption returned
rather than one found by a query, because two simultaneous sign-ups from
one issuer would make that a guess.

Only the hash is stored — a dump of this table with codes in it is a
stack of free accounts. The alphabet omits O/0 and I/l/1, because a code
that fails after being read down a phone looks like one that was refused.
The cap is on codes OUTSTANDING rather than ever issued: a lifetime cap
would punish the members actually inviting people.

The gate sits after the format checks, so a code is not spent on a
sign-up that was going to fail on a taken username.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-02 20:03:27 -04:00
co-authored by Claude Opus 4.8
parent deb55da5cf
commit cd790d0ad8
10 changed files with 498 additions and 23 deletions
+4
View File
@@ -99,6 +99,10 @@ FN_SMTP_TLS=true
# is a speed bump against lazy bulk registration, not a defence. Raise it
# further if your users share an address; lower it only if you know they do not.
FN_SIGNUP_MAX_PER_HOUR=50
# Invite-only phase (relay#43). Off, and inert without an end date — a gate
# with no expiry is one somebody has to remember to remove, and nobody does.
# FN_VOUCH_REQUIRED=false
# FN_VOUCH_UNTIL=0
# Federation (docker-compose.federated.yml only)
#
+16 -21
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: e592ce3fe1f1b4fe -->
source-sha256: 7c4f86cf179f27f2 -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="configuring-a-relay">Configuring a relay</h1>
@@ -320,28 +320,23 @@ network, and refusing them is the worse failure. <strong><code>0</code>
switches network counting off</strong>, leaving only the per-address
limit.</td>
</tr>
<tr>
<td><code>FN_SEND_RATE</code></td>
<td><code>600</code>/hr</td>
<td>Envelopes, per sender or per recipient when sealed.</td>
</tr>
<tr>
<td><code>FN_ATTACHMENT_RATE</code></td>
<td><code>60</code>/hr</td>
<td></td>
</tr>
<tr>
<td><code>FN_ATTACHMENT_BYTES_RATE</code></td>
<td><code>200 MiB</code>/hr</td>
<td></td>
</tr>
<tr>
<td><code>FN_ATTACHMENT_MAX_BYTES</code></td>
<td><code>10 MiB</code></td>
<td>Per blob.</td>
</tr>
</tbody>
</table>
<p>| <code>FN_VOUCH_REQUIRED</code> | <code>false</code> | Require an
invite from an existing member to sign up (relay#("#")43). 🔴 <strong>Inert
on its own</strong> — without <code>FN_VOUCH_UNTIL</code> the gate never
engages, deliberately: an invite-only server nobody can join is worse
than an open one. | | <code>FN_VOUCH_UNTIL</code> | unset | Unix seconds
after which vouching stops being required. 🔴 <strong>The gate turns
itself off.</strong> Vouching makes bulk sign-ups costly and gives every
account a provenance; it also slows genuine growth and creates an
in-group. That is a reasonable trade for a phase and a bad one for a
policy, so it cannot be switched on without saying when it stops. | |
<code>FN_SEND_RATE</code> | <code>600</code>/hr | Envelopes, per sender
or per recipient when sealed. | | <code>FN_ATTACHMENT_RATE</code> |
<code>60</code>/hr | | | <code>FN_ATTACHMENT_BYTES_RATE</code> |
<code>200 MiB</code>/hr | | | <code>FN_ATTACHMENT_MAX_BYTES</code> |
<code>10 MiB</code> | Per blob. |</p>
<h2 id="erasing-a-lost-device">Erasing a lost device</h2>
<p>Off. The relay does not carry instructions for a device to erase
itself.</p>
+39
View File
@@ -158,6 +158,31 @@ struct Config: Sendable {
/// discovery document says so, so clients can leave the affordance out
/// rather than offer a button that fails.
let contentReportsEnabled: Bool
/// Require an invite from an existing member to sign up (relay#43).
///
/// 🔴 **Off by default, and inert without `vouchUntil`.** Vouching makes
/// bulk sign-ups costly and gives every account a provenance; it also slows
/// genuine growth and creates an in-group. That is a reasonable trade for a
/// phase and a bad one for a policy, so this gate cannot be switched on
/// without also saying when it stops.
let vouchRequired: Bool
/// Just the two fields the vouch phase rule needs, so `AccountRoutes` can
/// ask the question without carrying the whole config.
struct VouchPhase: Sendable {
let vouchRequired: Bool
let vouchUntil: Int?
}
/// Unix seconds after which vouching stops being required (relay#43).
///
/// 🔴 **The gate turns itself off.** A phase whose end depends on somebody
/// remembering to change a variable is a policy with extra steps. Absent
/// while `vouchRequired` is on means the gate never engages at all, which
/// is the safe direction to fail: an invite-only server nobody can join is
/// worse than an open one.
let vouchUntil: Int?
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
@@ -243,6 +268,14 @@ struct Config: Sendable {
env[key].flatMap(Int.init) ?? fallback
}
/// ⚠️ Anything but a recognised truthy value is FALSE, including a
/// typo. A gate that switches itself on because somebody wrote `yes`
/// where the reader wanted `1` is worse than one that stays off.
func bool(_ key: String, _ fallback: Bool) -> Bool {
guard let raw = env[key]?.lowercased(), !raw.isEmpty else { return fallback }
return raw == "1" || raw == "true" || raw == "yes" || raw == "on"
}
return Config(
host: env["FN_HOST"] ?? "127.0.0.1",
port: int("FN_PORT", 8080),
@@ -300,6 +333,12 @@ struct Config: Sendable {
let raw = (env["FN_CONTENT_REPORTS"] ?? "").lowercased()
return !(raw == "false" || raw == "0" || raw == "no" || raw == "off")
}(),
vouchRequired: bool("FN_VOUCH_REQUIRED", false),
// ⚠️ Read as a unix timestamp rather than a date string: the relay
// has no timezone opinion and a date without one is ambiguous by
// exactly the amount that matters at a cutoff.
vouchUntil: int("FN_VOUCH_UNTIL", 0) > 0 ? int("FN_VOUCH_UNTIL", 0) : nil,
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
@@ -328,7 +328,9 @@ func configure(_ app: Application) async throws {
lifetime: config.tokenLifetime),
limits: makeLimiter("signin", AccountRoutes.signinLockout),
signupMaxPerHour: config.signupMaxPerHour,
signupMaxPerNetworkPerHour: config.signupMaxPerNetworkPerHour)
signupMaxPerNetworkPerHour: config.signupMaxPerNetworkPerHour,
vouchRequired: config.vouchRequired,
vouchUntil: config.vouchUntil)
try register(account: accounts, config: config, on: app)
// The shipped iOS client's surface, mounted where it already looks.
@@ -1042,6 +1044,12 @@ func register(mobile m: MobileRoutes, on app: Application) throws {
func register(account a: AccountRoutes, config: Config, on app: Application) throws {
let account = app.grouped("account")
// relay#43 — mint an invite to hand to somebody. Authenticated: only a
// member can vouch, which is the entire mechanism.
account.post("vouch") { req in
try await a.issueVouch(req, now: unixNow())
}
account.post("signup") { req in
try await a.signup(req, clientIP: clientIP(req), now: unixNow())
}
+3
View File
@@ -80,6 +80,9 @@ struct SignupRequest: Content {
let email: String
let password: String
let display_name: String?
/// An invite from an existing member (relay#43), when the server is in its
/// vouched phase. Ignored entirely when it is not.
let vouch: String?
}
struct SigninRequest: Content {
+1 -1
View File
@@ -612,6 +612,6 @@ enum RelayMigrations {
// ⚠️ 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()]
CreateContentReport(), CreateVouchCode()]
}
}
+38
View File
@@ -33,6 +33,18 @@ struct AccountRoutes: Sendable {
/// ordinary use.
var signupMaxPerNetworkPerHour: Int = signupMaxPerNetworkDefault
/// Whether this server is in its invite-only phase, and until when
/// (relay#43). Passed as the pair rather than the whole `Config` for the
/// same reason as `signupMaxPerHour`.
var vouchRequired: Bool = false
var vouchUntil: Int? = nil
/// Just enough of `Config` for `VouchCode.required`, so the phase rule
/// lives in one place rather than being re-implemented here.
private var vouchConfig: Config.VouchPhase {
Config.VouchPhase(vouchRequired: vouchRequired, vouchUntil: vouchUntil)
}
/// Five failures per IP+username per fifteen minutes, matching the plugin.
///
/// This route turns a password into a long-lived credential, so without a
@@ -131,6 +143,18 @@ struct AccountRoutes: Sendable {
throw WireError("weak_password", "Passwords must be at least 8 characters.", 400)
}
// relay#43 — the invite gate, checked after the format rules and
// before anything is written.
//
// ⚠️ Deliberately AFTER the cheap validation: a code spent on a
// sign-up that was going to fail on a taken username is a code
// somebody has to be given again. The rate limit above already ran, so
// this ordering costs no protection.
var spentCodeID: Int? = nil
if VouchCode.required(config: vouchConfig, now: now) {
spentCodeID = try await Self.redeem(input.vouch, on: request.db, now: now).codeID
}
let email = input.email.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if try await User.query(on: request.db).filter(\.$username == username).first() != nil {
throw WireError("username_taken", "That username is taken.", 409)
@@ -148,6 +172,20 @@ struct AccountRoutes: Sendable {
user.chosenStatus = ChosenStatus.online.rawValue
try await user.create(on: request.db)
// relay#43 — the provenance the scheme exists for. Written after the
// account so it can name it; the code was already marked spent above,
// so a failure here cannot hand it back out.
//
// ⚠️ Attached to the row redemption returned, not one found by a
// query. Two simultaneous sign-ups against one issuer would otherwise
// make that a guess.
if let spentCodeID, let newID = user.id,
let row = try await VouchCode.find(spentCodeID, on: request.db) {
row.usedBy = newID
try await row.save(on: request.db)
}
guard let userID = user.id else {
throw WireError("server_error", "Could not create the account.", 500)
}
+187
View File
@@ -0,0 +1,187 @@
import Crypto
import Fluent
import Foundation
import SQLKit
import Vapor
/// A sign-up code issued by an existing member (relay#43).
///
/// Vouching scales badly and works well early: it ties each new account to
/// somebody who already has one, which makes bulk sign-ups costly and gives
/// every account a provenance.
///
/// 🔴 **It also slows genuine growth and creates an in-group**, which is why
/// the ticket calls for a phase with a stated end date rather than a permanent
/// policy — and why `Config.vouchUntil` is not optional when vouching is on.
/// A gate with no expiry is one somebody has to remember to remove, and nobody
/// ever does.
final class VouchCode: Model, @unchecked Sendable {
static let schema = "vouch_codes"
@ID(custom: "id", generatedBy: .database) var id: Int?
/// 🔴 **The hash, never the code.** A vouch code is a bearer credential: a
/// dump of this table with codes in it is a stack of free accounts. Same
/// posture as `DeliveryToken` and app credentials.
@Field(key: "code_hash") var codeHash: String
/// The member who issued it.
@Field(key: "issuer_id") var issuerID: Int
/// The account it created. Filled in after the account exists, so it is
/// PROVENANCE and not the spent flag.
///
/// 🔴 **`usedAt` is what makes a code spent, not this.** Reading spentness
/// off `usedBy` was wrong and the tests caught it: redemption happens
/// before the account has an id, so a redeemed code still had `usedBy ==
/// nil` and could be spent again without limit — which is the whole
/// mechanism defeated. Worse, a sign-up that failed after redeeming left a
/// code that looked unspent for ever.
///
/// ⚠️ Kept rather than deleted on use: the provenance is the entire point
/// of the scheme, and a row removed on redemption throws away the only
/// record of who vouched for whom.
@OptionalField(key: "used_by") var usedBy: Int?
@Field(key: "created_at") var createdAt: Int
@OptionalField(key: "used_at") var usedAt: Int?
init() {}
/// How many unspent codes one member may hold at a time.
///
/// ⚠️ A cap on OUTSTANDING codes, not on codes ever issued. Capping the
/// lifetime total would punish the members actually inviting people, which
/// is the behaviour the scheme exists to encourage; capping how many can be
/// in flight bounds what one compromised account is worth.
static let outstandingPerMember = 5
static func hash(_ code: String) -> String {
Data(SHA256.hash(data: Data(code.utf8))).base64EncodedString()
}
/// A short code somebody can read down a phone.
///
/// ⚠️ Deliberately not base64: `I`/`l`/`1` and `O`/`0` are the characters
/// people mistype when copying by hand, and a code that fails because it
/// was read aloud badly looks like a code that was refused.
static let alphabet = Array("ABCDEFGHJKMNPQRSTUVWXYZ23456789")
static func mint() -> String {
var bytes = [UInt8](repeating: 0, count: 10)
var generator = SystemRandomNumberGenerator()
for i in bytes.indices { bytes[i] = UInt8.random(in: 0...255, using: &generator) }
let body = bytes.map { alphabet[Int($0) % alphabet.count] }
return String(body[0..<5]) + "-" + String(body[5..<10])
}
/// Whether vouching still applies, given the phase's end date (relay#43).
///
/// 🔴 **The gate turns itself off.** The ticket's own framing is that this
/// is a phase, not a policy, and a phase whose end depends on somebody
/// remembering to change a variable is a policy with extra steps.
static func required(config: Config.VouchPhase, now: Int) -> Bool {
guard config.vouchRequired else { return false }
guard let until = config.vouchUntil else { return false }
return now < until
}
}
extension AccountRoutes {
/// Mint a code to hand to somebody (relay#43).
///
/// ⚠️ Authenticated with an app credential, like every other member
/// action here. Only a member can vouch — that is the whole mechanism, and
/// an unauthenticated mint would be an open sign-up route with a longer
/// name.
func issueVouch(_ request: Request, now: Int) async throws -> Response {
guard let basic = request.headers.basicAuthorization,
let userID = try await verifyAppCredential(basic.password, on: request.db)
else { throw WireError.unauthorized("Credential not accepted.") }
return try await Self.issueVouch(request, userID: userID, now: now)
}
/// ⚠️ Static: it needs the database and a user id and nothing from the
/// route's own state, so it is testable without standing up an issuer and
/// a rate limiter that have no bearing on the behaviour.
static func issueVouch(_ request: Request, userID: Int, now: Int) async throws -> Response {
let outstanding = try await VouchCode.query(on: request.db)
.filter(\.$issuerID == userID)
// 🔴 `usedAt`, not `usedBy`: a code is spent when it is redeemed,
// and `usedBy` is only filled in once the account it made exists.
.filter(\.$usedAt == nil)
.count()
guard outstanding < VouchCode.outstandingPerMember else {
throw WireError("too_many_codes",
"You already have \(VouchCode.outstandingPerMember) unused invites. "
+ "Wait until some are used.", 429)
}
let code = VouchCode.mint()
let row = VouchCode()
row.codeHash = VouchCode.hash(code)
row.issuerID = userID
row.createdAt = now
try await row.create(on: request.db)
// Returned once, at the moment it is minted. Only the hash is kept, so
// there is no route that can show it again.
return try .json(["code": code])
}
/// Spend a code, or explain why it cannot be spent.
///
/// ⚠️ Marked used BEFORE the account is created by the caller, so two
/// simultaneous sign-ups cannot both spend one code. The unique index on
/// `code_hash` plus this check is what makes it single-use.
static func redeem(_ code: String?, on db: any Database, now: Int)
async throws -> (issuer: Int, codeID: Int) {
guard let code, !code.isEmpty else {
throw WireError("vouch_required",
"This server is invite-only at the moment. You need a code from "
+ "somebody who already has an account.", 403)
}
let normalized = code.uppercased().trimmingCharacters(in: .whitespaces)
guard let row = try await VouchCode.query(on: db)
.filter(\.$codeHash == VouchCode.hash(normalized))
.first()
else {
throw WireError("vouch_invalid", "That invite code is not valid.", 403)
}
guard row.usedAt == nil else {
// ⚠️ Distinguished from "not valid". A code that was already used
// is the commonest honest failure — two people sent the same one —
// and telling them it is invalid sends them looking for a typo.
throw WireError("vouch_spent", "That invite code has already been used.", 403)
}
row.usedAt = now
try await row.save(on: db)
// The row id comes back so the caller can attach the account to THIS
// code once it exists. Finding it again by a query would be a guess
// under two simultaneous sign-ups from one issuer.
return (row.issuerID, row.id ?? 0)
}
}
struct CreateVouchCode: AsyncMigration {
func prepare(on database: any Database) async throws {
try await database.schema(VouchCode.schema)
.field("id", .int, .identifier(auto: true))
.field("code_hash", .string, .required)
.field("issuer_id", .int, .required)
.field("used_by", .int)
.field("created_at", .int, .required)
.field("used_at", .int)
// 🔴 The uniqueness that makes a code single-use even under two
// simultaneous redemptions.
.unique(on: "code_hash")
.create()
if let sql = database as? any SQLDatabase {
try? await sql.raw("""
CREATE INDEX IF NOT EXISTS vouch_codes_issuer
ON vouch_codes (issuer_id)
""").run()
}
}
func revert(on database: any Database) async throws {
try await database.schema(VouchCode.schema).delete()
}
}
@@ -0,0 +1,198 @@
import Fluent
import Foundation
import Testing
import Vapor
@testable import FlamenetRelay
/// Member-vouched sign-up codes (relay#43).
///
/// Vouching ties each new account to somebody who already has one, which makes
/// bulk sign-ups costly and gives every account a provenance. It also slows
/// genuine growth and creates an in-group — so it is a phase with a stated end
/// date rather than a policy, and the tests that matter most are the ones about
/// it being off.
@Suite("vouch codes")
struct VouchCodeTests {
private let now = 1_700_000_000
private func phase(on: Bool, until: Int?) -> Config.VouchPhase {
Config.VouchPhase(vouchRequired: on, vouchUntil: until)
}
// -- The gate is off ------------------------------------------------------
/// 🔴 The default. A relay that became invite-only because somebody added
/// a table would be a change nobody asked for.
@Test("vouching is off unless switched on")
func offByDefault() {
#expect(!VouchCode.required(config: phase(on: false, until: now + 10), now: now))
}
/// 🔴 **A gate with no end date never engages.** The ticket asks for a
/// phase, and a phase whose end depends on somebody remembering to change a
/// variable is a policy with extra steps. Failing open is the safe
/// direction: an invite-only server nobody can join is worse than an open
/// one.
@Test("vouching without an end date never engages")
func inertWithoutAnEndDate() {
#expect(!VouchCode.required(config: phase(on: true, until: nil), now: now))
}
/// 🔴 The gate turns itself off when the phase ends, with nobody acting.
@Test("vouching stops when the phase ends")
func stopsAtTheEndDate() {
#expect(VouchCode.required(config: phase(on: true, until: now + 10), now: now))
#expect(!VouchCode.required(config: phase(on: true, until: now - 1), now: now))
}
/// The boundary: at the stated moment it is already over.
@Test("the end date is exclusive")
func endDateIsExclusive() {
#expect(!VouchCode.required(config: phase(on: true, until: now), now: now))
}
// -- Codes ----------------------------------------------------------------
/// ⚠️ The alphabet excludes the characters people mistype reading a code
/// aloud — a code that fails because it was misheard looks like one that
/// was refused.
@Test("a minted code avoids ambiguous characters")
func codesAvoidAmbiguousCharacters() {
for _ in 0..<200 {
let code = VouchCode.mint()
#expect(!code.contains("0"))
#expect(!code.contains("O"))
#expect(!code.contains("1"))
#expect(!code.contains("I"))
#expect(!code.contains("L"))
}
}
/// 🔴 Only the hash is stored: a dump of this table with codes in it would
/// be a stack of free accounts.
@Test("the code itself is never stored")
func onlyTheHashIsStored() async throws {
try await withRelay { h in
let code = VouchCode.mint()
let row = VouchCode()
row.codeHash = VouchCode.hash(code)
row.issuerID = 1
row.createdAt = now
try await row.create(on: h.db)
let stored = try await VouchCode.query(on: h.db).all()
#expect(stored.count == 1)
#expect(stored[0].codeHash != code)
#expect(stored[0].codeHash == VouchCode.hash(code))
}
}
// -- Redemption -----------------------------------------------------------
private func issue(_ h: RelayHarness, issuer: Int = 1) async throws -> String {
let code = VouchCode.mint()
let row = VouchCode()
row.codeHash = VouchCode.hash(code)
row.issuerID = issuer
row.createdAt = now
try await row.create(on: h.db)
return code
}
@Test("a valid code names its issuer")
func aValidCodeNamesItsIssuer() async throws {
try await withRelay { h in
let code = try await issue(h, issuer: 7)
let redeemed = try await AccountRoutes.redeem(code, on: h.db, now: now)
#expect(redeemed.issuer == 7)
}
}
/// 🔴 Single use. Without this the scheme is a password one member can
/// hand to a hundred people.
@Test("a code cannot be spent twice")
func aCodeCannotBeSpentTwice() async throws {
try await withRelay { h in
let code = try await issue(h)
_ = try await AccountRoutes.redeem(code, on: h.db, now: now)
var reason = ""
do { _ = try await AccountRoutes.redeem(code, on: h.db, now: now) }
catch let e as WireError { reason = e.code }
#expect(reason == "vouch_spent")
}
}
/// ⚠️ "Already used" is distinguished from "not valid": the former is the
/// commonest honest failure, and calling it invalid sends somebody looking
/// for a typo that is not there.
@Test("an unknown code is refused differently from a spent one")
func unknownDiffersFromSpent() async throws {
try await withRelay { h in
var reason = ""
do { _ = try await AccountRoutes.redeem("ZZZZZ-ZZZZZ", on: h.db, now: now) }
catch let e as WireError { reason = e.code }
#expect(reason == "vouch_invalid")
}
}
@Test("a missing code is refused with a reason somebody can act on")
func missingCodeIsRefused() async throws {
try await withRelay { h in
var reason = ""
do { _ = try await AccountRoutes.redeem(nil, on: h.db, now: now) }
catch let e as WireError { reason = e.code }
#expect(reason == "vouch_required")
}
}
/// ⚠️ Codes are read off screens and said down phones.
@Test("a code is accepted in any case and with stray spaces")
func codesAreForgivingOfTranscription() async throws {
try await withRelay { h in
let code = try await issue(h, issuer: 3)
let redeemed = try await AccountRoutes.redeem(
" \(code.lowercased()) ", on: h.db, now: now)
#expect(redeemed.issuer == 3)
}
}
// -- Outstanding cap ------------------------------------------------------
/// ⚠️ Caps codes IN FLIGHT rather than issued ever: a lifetime cap would
/// punish the members actually inviting people, which is the behaviour the
/// scheme exists to encourage.
@Test("a member may hold only so many unused invites")
func outstandingCodesAreCapped() async throws {
try await withRelay { h in
let d = h.device(user: 1, id: "aaaa-0001")
_ = try await h.register(d, on: h.db)
for _ in 0..<VouchCode.outstandingPerMember { _ = try await issue(h, issuer: 1) }
let req = h.request(.POST, "/account/vouch", as: d, body: Data(), now: now)
var reason = ""
do { _ = try await AccountRoutes.issueVouch(req, userID: 1, now: now) }
catch let e as WireError { reason = e.code }
#expect(reason == "too_many_codes")
}
}
/// Spending one frees a slot: the cap is on what is outstanding.
@Test("spending an invite frees a slot")
func spendingFreesASlot() 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 codes: [String] = []
for _ in 0..<VouchCode.outstandingPerMember {
codes.append(try await issue(h, issuer: 1))
}
_ = try await AccountRoutes.redeem(codes[0], on: h.db, now: now)
let req = h.request(.POST, "/account/vouch", as: d, body: Data(), now: now)
let response = try await AccountRoutes.issueVouch(req, userID: 1, now: now)
#expect(response.status == .ok)
}
}
}
+3
View File
@@ -125,6 +125,9 @@ Defaults are sane; raise them only with a reason.
| `FN_BUNDLE_RATE_TARGET` | `6`/hr | Per requester-target pair. Deliberately **not** metered per target alone — that would let one abuser make a popular account unstartable for everyone. |
| `FN_SIGNUP_MAX_PER_HOUR` | `50`/hr | New accounts per IP. ⚠️ An IP is not a person — carrier CGNAT, offices and campuses all share one. It was `5`, which refused the sixth user behind any shared connection. Per-IP is trivially rotated, so treat this as a speed bump against lazy bulk registration rather than a defence. |
| `FN_SIGNUP_MAX_PER_NETWORK` | `400`/hr | New accounts per network — a /24 for IPv4, a /48 for IPv6. The per-address limit counts a rented subnet as separate strangers, and more addresses is the cheapest thing an abuser buys. Deliberately generous: a campus or a carrier behind CGNAT is thousands of real people on one network, and refusing them is the worse failure. **`0` switches network counting off**, leaving only the per-address limit. |
| `FN_VOUCH_REQUIRED` | `false` | Require an invite from an existing member to sign up (relay#43). 🔴 **Inert on its own** — without `FN_VOUCH_UNTIL` the gate never engages, deliberately: an invite-only server nobody can join is worse than an open one. |
| `FN_VOUCH_UNTIL` | unset | Unix seconds after which vouching stops being required. 🔴 **The gate turns itself off.** Vouching makes bulk sign-ups costly and gives every account a provenance; it also slows genuine growth and creates an in-group. That is a reasonable trade for a phase and a bad one for a policy, so it cannot be switched on without saying when it stops. |
| `FN_SEND_RATE` | `600`/hr | Envelopes, per sender or per recipient when sealed. |
| `FN_ATTACHMENT_RATE` | `60`/hr | |
| `FN_ATTACHMENT_BYTES_RATE` | `200 MiB`/hr | |