Everything the bot does today is reactive: Mastodon creates the account,
the account.created webhook arrives, we classify the signup IP and email
domain, and then we block the range and suspend what already exists.
POST /api/reputation is the pre-emptive half. A caller asks about an IP
and/or email domain; we classify it, record the answer, register the
matching Admin::IpBlock / EmailDomainBlock, and only then return. A
caller that runs its own block lookup immediately afterwards therefore
finds a block that did not exist when its request started. The intended
caller is Mastodon's signup path, which does exactly that.
It writes, so unlike /api/blocks it is authenticated and refuses to serve
without a key (REPUTATION_API_KEY): an unauthenticated endpoint that
installs IP blocks is a denial-of-service lever, so there is no open mode.
Notes on the pieces that aren't obvious:
- REPUTATION_API_MAX_LOOKUPS_PER_HOUR caps *fresh* provider lookups. The
caller hits this on every view of the signup form, not just on
submission, so without a ceiling a crawler walking /auth/sign_up from a
new range each time would set our ipapi.is bill. Over budget we answer
"unknown" and install nothing -- never "flagged". Cache hits are free
and uncounted, and the webhook path never spends from this budget.
- Blocks installed here are recorded in their own table, keyed by the
CIDR/domain rather than by account (there is no account yet), and are
folded into /api/blocks and the reconcile pass so neither one
under-reports as blocking shifts to the pre-signup path. The published
entry shape is unchanged; a range blocked before anyone signed up from
it simply reads "signups": 0.
- A repeat query for an already-installed block skips the Mastodon round
trip. This runs on a live signup request, so that is saved latency.
ipblock_registered and domain_blocked only ever meant "we posted this
block once" — they said nothing about whether it still exists. When the
API went live it was publishing 24 /32 ranges that had been removed by
hand on 2026-07-21, so ~17% of the IP list didn't block anything. That's
a bad thing to hand another admin an importer for.
app/reconcile_blocks.py fetches Mastodon's live Admin::IpBlock and
Admin::EmailDomainBlock lists and maintains a new block_active column on
both tables: off for rows whose block is gone or whose expires_at has
elapsed, back on for any that reappear. The API serves only active rows.
Re-registering a block reactivates its row, so a range the bot blocks
again after a manual cleanup comes back on its own.
A failed fetch changes nothing — not a 403 from a missing scope, not
Mastodon being down, not a half-failure where one list answers and the
other doesn't. An outage must never be read as "every block was
removed"; the list degrades to over-reporting rather than emptying
itself, and there's a test for each of those paths.
Needs admin:read:ip_blocks + admin:read:email_domain_blocks, added to
the live token in place (scopes updated on the existing Doorkeeper
token, matched by digest) rather than by minting and rotating, so the
token value is unchanged and .env needed no edit.