v1.0.0
Added
-
current-envnow also acceptsbeta, a fourth environment alongside
staging/prod/dev. EveryDiffers[T]config key gains an optional
betavalue (config.Differs[T].Beta), consulted only whencurrent-env
isbetaand falling back tostagingwhen unset — same mechanism as
dev's override, but withoutdev's relaxed Staging/Prod requirement:
betais validated exactly likestaging/prod(ValidateDiffers),
since it's a real running deployment rather than a personal machine. In
practice this means most config (DB, tokens, etc.) can stay shared with
staging, and only keys that genuinely differ per deployment — like
sites.frontend— need an explicitbeta:value. -
bgtaskspackage: a new home for Popplio's own periodic background jobs,
separate fromarcadia/tasks(the staff bot's jobs, which only run when
Arcadia is configured) so core platform features don't depend on staff
tooling being set up. First job:bot_uptime_check, which periodically
records whether every listed bot is currently online in the main server
intobots.uptime/total_uptime/uptime_last_checked. These columns
have existed since the Rust port but were never actually written to —
Arcadia's old uptime checker (src/tasks/__toberewritten/uptime.rs)
didn't even compile against the serenity version it was last touched
against, and was explicitly never ported (seearcadia/CONFORMANCE.md).
Reads presence straight from Popplio's own gateway cache (it already
requests the Presence intent) rather than Infernoplex, which deliberately
never requests it. -
servers.avatar: servers previously had no icon anywhere (index listing,
detail page, or the staff panel's server search all showed a blank/
initials fallback) — the old cache-server subsystem used to synthesize
this from its own CDN cache, and nothing replaced it after that was
retired (exp/remove_cache_servers.sql). Populated once at Add Server
time from the invite resolution already done there, and kept fresh
afterward by Infernoplex'sserversynctask, which now also syncs every
listed server's icon (not just opted-in ones' emojis/stickers) from its
gateway cache. Requires the newservers.avatarcolumn
(exp/add_servers_avatar.sql, needs to be applied manually against the
database like otherexp/scripts). -
Webhooks gained a new
hmac_authmode (hmac_authon
POST/PATCH .../webhooks): the payload is sent as plain JSON with an
X-Webhook-Signature: sha256=<hex hmac>header, the same shape GitHub and
Stripe webhooks already use. It's now the recommended mode for new
webhooks the previous default ("splashtail": AES-GCM encrypted body,
nonce-chained double HMAC across two headers) required implementing
decryption just to verify a delivery, not just a signature check.
Existing webhooks are unaffected:hmac_authdefaults to off and the
splashtail/simple_authprotocols are unchanged and fully supported
this only adds a third option, it doesn't remove or alter the other two.
Requires the newwebhooks.hmac_authcolumn
(exp/webhookhmacauth.sql, needs to be applied manually against the
database like otherexp/scripts). -
current-envnow also acceptsdev, a third environment alongside
staging/prod. EveryDiffers[T]config key (config/config.go) gains
an optionaldevvalue, only consulted whencurrent-envisdev, and
only used if actually set — an unsetdevvalue falls back tostaging,
so no existingconfig.yamlneeds to change. Lets a local checkout run
against things like a personal Discord bot application
(discord_auth.token,arcadia.token) without touching the real staging
config.discord_auth.token(Popplio's own bot token) is now itself a
Differs[string]rather than a single flat value, so it can differ across
environments the same way Arcadia's staff bot token already could.
Anything gated to "real production" (Paypal live vs sandbox API base,
Arcadia's background tasks, the staff bot's guild-member-join
announcements, the staging-sensitive-permission gate) now treatsdevthe
same asstagingrather than falling through to production behavior. -
PUT /serversadd a server to the list directly from a Discord invite
link. Resolves the guild via the invite (the tracking bot does not need to
already be in the server), rejects duplicates and blacklisted vanities,
and auto-creates an owning team the same way bot submission already does
(or attaches to an existing team the submitter hasbot.add-equivalent
permission on). -
Packs can now include servers alongside bots: a
serverscolumn,
resolution into fullIndexServerobjects, and matching validation on
bothPOST /packs(create) andPATCH /packs/{url}(edit). A pack must
contain at least one bot or server between the two fields. -
Bots can self-report presence (
online/idle/dnd/offline) via
POST /bots/stats, alongside the existing server/shard/user stats. The
reported value is folded into the resolveduser.statusreturned
everywhere a bot's info appears, since most bots don't share a guild with
the tracking bot for a real gateway presence to be read from. -
Bots with no explicit self-reported status but a real track record of
posting stats (a nonzero server count from a stats post within the last
24 hours) are now shown asonlinerather than falling back to
dovewing's almost-always-offline gateway-derived status. -
GET /servers/meta?invite=...resolves a Discord invite to a preview of
the server it points to (name, icon, member counts, and whether it's
already listed) without adding anything — lets a client show what's about
to be submitted before Add Server is actually called. Shares its invite
resolution logic withPUT /serversvia a newResolveInvitehelper. -
Servers can opt in to showing their custom emojis and stickers on their
listing page via a newshow_emojissetting (PATCH /servers/{id}/settings).
GET /servers/{id}now includesemojis/stickers/emojis_synced_at,
always empty unless the owner has opted in. The actual snapshot is synced
periodically by the tracking bot (Infernoplex), not fetched live per
request, and requires the bot to currently be a member of the server —
Popplio itself never talks to Discord for this. -
GET /servers/metanow also reportsbot_present/bot_invite_urlby
asking Infernoplex's Sorbet API whether the tracking bot is currently a
member of the resolved guild, via a newCheckBotGuildPresencehelper.
Best-effort: any failure to reach Infernoplex is treated as "not present"
rather than failing the request.
Changed
- Bots now support downvotes, matching servers/teams/packs
(votes.EntityVoteInfono longer hardcodesSupportsDownvotes = falsefor
thebottarget type). meta.popplio_proxynow defaults tohttps://gateway.nodebyte.host/proxy/discord
(the shared parent-company gateway), replacing the old local
http://127.0.0.1:3219twilight-http-proxy convention. Both Popplio's own
bot client (state.Setup) and Arcadia's separate staff bot
(arcadia/dclient) now route their REST traffic through it via
rest.WithURL/rest.WithHTTPClient(state.ProxyRestOpts). Since that
gateway authenticates every request with its own shared bot credential by
default, each client sends its own token via anX-Upstream-Authorization
header instead, which the gateway forwards as the realAuthorization
header sent to Discord — so Popplio and Arcadia's staff bot each keep
their own distinct bot identity rather than both authenticating as
whichever bot the gateway holds.EntityGetVoteCount(used by nearly every bot/server/team/user/pack
detail and list endpoint) now counts up- and down-votes in a single query
withFILTER, instead of two separateCOUNT(*)round trips.- Bot/server index resolution (
ResolveIndexBot/ResolveIndexServer,
called byGET /bots/@all,GET /servers/@all, search, random, the bots
index, packs, team entities, and user profiles) now resolves every row in
a page concurrently viaerrgroupinstead of one row at a time — each
row's dovewing/vanity/vote lookups are independent, so a page of results
no longer pays for them sequentially. GET /list/current-statusnow issues both the Instatus and UptimeRobot
requests with the request's own context and a bounded client timeout,
instead of an unboundedhttp.Get/http.NewRequestthat could hang the
handler indefinitely if the upstream stalled.- Deduplicated the
pagequery-parameter parsing copy-pasted across nine
endpoints (each with a slightly different error response for the same
invalid-page case) into a sharedpagination.Parsehelper. DELETE /users/{uid}/packs/{id}andPATCH /users/{uid}/packs/{id}each
folded two sequential "does the pack exist" / "who owns it" queries into
one.- The generic error bodies returned when a failure carries no specific
message of its own (constants/constants.go— 404s, 400s, 403s, 401s,
500s, 405s, and missing-body errors) were all a "Slow down, bucko!" joke
string. Replaced with plain, professional messages that actually describe
the failure.
Fixed
PUT /serversandPUT /botsstill wrote the legacy wildcard string
global.*into a new team'steam_members.flagswhen creating the
owner's membership, instead of the flat model'sownerpermission
(perms.EntityOwner).exp/rewrite/flatperms.sqlconverts this
correctly for existing rows, but every server/bot added after running
that migration created a team whose owner held a permission string the
flat permission checker doesn't recognize as anything — silently locking
them out of managing their own new listing (edit_servers/edit_bots
checks fail, sinceglobal.*isn'townerand isn't a declared
permission either).arcadia/tasks/cleaners.go'sTeamCleanertask had
the same bug in both directions: it looked for orphaned-of-owner teams by
queryingflags @> ARRAY['global.*'](which will now never match
anything, since the migration already converted every existing row) and
wroteglobal.*back when promoting a replacement owner. All three now
useperms.EntityOwner.- Every mod-log notification embed (
PUT /servers,PUT /bots,
DELETE /bots/{id}, and the twoPATCH .../settingsendpoints below)
built its link back to the site withSites.Frontend.Production(),
forcing the production URL regardless of which environment the action
actually happened in. On staging/beta, this meant either a broken link
(ifsites.frontend.prodwasn't configured on that box at all — its
Production()has no fallback, so an unset value silently becomes an
invalid relative-path embed URL and Discord rejects the whole message
with50035) or a link to an entity that only exists in a different
environment's database. Switched to.Parse(), which resolves against
whichever environment is actually running. PATCH /servers/{id}/settingsandPATCH /bots/{id}/settingsreturned a
500 whenever their mod-log notification embed failed to send — including
the guaranteed case for servers, which built its embed with
Thumbnail: &discord.EmbedResource{}(a present-but-empty resource,
which Discord's API rejects outright with50035: Invalid Form Body
rather than treating as "no thumbnail"). The underlying update had
already succeeded in both cases — the error message even said so — so a
caller retrying on this 500 risked double-submitting. The thumbnail is
now omitted when there's no avatar instead of sent empty, servers' embed
now uses the realservers.avatarvalue instead of nothing, and a
failure to post the notification is logged rather than failing the
request, matching the existing pattern inPUT /servers.PUT /serverswrote every field into the wrong column:createServerArgs's
hand-written value order didn't matchtypes.CreateServer's field
declaration order, which is whatdb.GetCols/the generated column list
actually follow. Values were bound to columns purely by position, so e.g.
server_idwas written intoinvite,nameintoshort, and
extra_links(a[]Link) intotags(atext[]) — the last of which is
what surfaced as acannot find encode planerror, since a[]Linkcan't
encode into atext[]column.createServerArgsnow lists values in the
same order as the struct, with a comment on both explaining they must stay
in sync (the existing length check oncreateServerColsArr/serverArgs
only ever caught the two lists having different lengths, not entries being
out of order relative to each other).servers.extra_linkswas stilltext[]in the deployed database while
the application code (anddata/seed-ci.json's own schema conformance
check) has expectedjsonbfor a while, matchingbots/teams/users'
extra_linkscolumns. Migrated viaexp/fix_servers_extra_links_type.sql
(needs to be applied manually against the database like otherexp/
scripts, same asexp/rewrite/*.sql). Independent of the column-ordering
bug above, but was masking it: the ordering bug meantextra_links
received whatever value happened to land at that position, which could
vary by field type in ways that made this fix look sufficient on its own.- Startup logged
error while setting presence err="no gateway configured"
on every run:Discord.SetPresencewas called right after
Discord.OpenShardManagerreturned, but that only means the shards
started connecting, not that the gateway session is actually usable yet
(that's only confirmed later, asynchronously, via theOnGuildsReady
event).SetPresencenow runs from inside theOnGuildsReadyhandler
instead, once shards are confirmed ready. current-env: devstill required a realstagingandprodvalue for
everyDiffers[T]config key, and for every Arcadia staff-server
channel/role/server ID, defeating the point ofdev: a local checkout
needed a fully populated staging/prod config, including Arcadia secrets,
just to start.Differs[T]'s requirement is now environment-aware (dev
only needsdevor astagingfallback to resolve;staging/prod
keep the original both-required behavior), and Arcadia's staff-server
fields use a newrequirednotdevvalidator so they're only required
outside ofdev.GET /servers/meta's route registration was missing theExtDataentry
BaseSanityCheckrequires whenever a route declaresAuth, so the
server panicked at startup ("Base sanity check failed: permissionCheck
not found in route.ExtData") before it could serve a single request.PATCH .../webhooks/{id}silently ignoredsimple_authin the request
body — theUPDATEstatement never included that column, so a webhook's
auth mode could only ever be set at creation, never changed afterward.GET /list/current-status's Redis cache never actually worked: it passed
a rawmap[string]anytoSet, which go-redis cannot serialize (returns
"redis: can't marshal map[string]interface{}") — an error that was
never checked, so every request silently round-tripped to Instatus or
UptimeRobot instead of using the 3-minute cache the code's own comment
says exists. Now JSON-marshals beforeSetand unmarshals back into the
same shape on a hit, so the response is identical whether it came from
cache or not (previously a hit, had it ever occurred, would have returned
a raw string instead of the status object a miss returns).add_review/edit_review/remove_revieweach called
state.Redis.Del(ctx, "rv-"+targetId+"-"+targetType)on every mutation,
invalidating a cache key that is neverSetorGetanywhere in the
codebase — three no-op Redis round trips per review change. Removed.- The OAuth authorization-code replay check (
create_oauth2_login) used a
separateExistsfollowed bySetto mark a code used, which is not
atomic: two concurrent requests carrying the same code could both pass
Existsbefore either calledSet, letting the same code be redeemed
twice — exactly the race the code's own comment says it closes, but
didn't. Now usesSetNX, which checks and marks the code used in one
atomic round trip. - Several route handlers (
delete_pack,patch_pack,current_status)
returned a bareuapi.DefaultResponse(http.StatusInternalServerError)on
DB/upstream failures without logging anything, so some production 500s
were invisible in the logs. They now go throughresp.Err, which is what
the sharedapi/resppackage exists for. - A background goroutine filtering empty entries out of the Stripe webhook
IP allowlist mutated the slice while ranging over its original indices, a
classic Go bug that silently skips the element shifted into a just-removed
slot — consecutive empty lines in Stripe's IP list could leave stale
entries in a security-relevant allowlist. Now builds a filtered copy
instead of mutating in place. - Startup panicked the entire process on a transient Stripe API/network
failure (deleting existing webhooks, creating the new one, or fetching its
IP allowlist) instead of disabling Stripe webhook support and continuing,
unlike the equivalent Paypal setup a few lines above it, which already
degraded gracefully. webhooks/senderusedpanic()as its input-validation strategy for a
handful of preconditions, including from inside an unrecovered goroutine
(the randomized "send a bad webhook to test auth" path) — a single
malformed webhook payload reaching that path could crash the whole
process rather than just fail one webhook delivery. Preconditions now
return errors instead.- Several fire-and-forget goroutines spawned from request handlers (bot/server
detail-page analytics, review garbage collection, Stripe perk delivery,
vote logging and webhook dispatch) had norecover(), so a panic in any of
them would take down the whole process instead of just that background
task. Two of them (create_user_entity_vote's webhook-send goroutine and
the Stripe perk-delivery goroutine) also wrote to anerrvariable shared
with their enclosing handler, a data race; both now use a goroutine-local
variable. data/seed-ci.json, the schema snapshot thedb_fields_check.pyCI test
checks Go structdbtags against, had fallen out of sync with several
recently added columns (bots.self_status,packs.servers,
servers.show_emojis/emojis/stickers/emojis_synced_at), breaking the
test build. Also found and excludedbots.cache_server_uninvitable, a
real DB column with no corresponding Go struct field anywhere in the
codebase, via the existingignore_fieldsconvention.- Team votes were never resolved when a team was embedded inside a user's
profile response (GET /users/{id}) — every embedded team silently
reported 0 votes regardless of its real count. GET /users/{id}never requested team member data (team_member) when
resolving a user's teams, onlybotandserver— so any client relying
on that response to determine a user's permissions on a team-owned entity
(e.g. "can I edit this bot?") always saw an empty permission set.- The tracking bot's Discord presence update was hard-gated to the
production environment only, and silently swallowed any error from
actually setting it (the failure check was reading a stale variable from
an earlier, unrelated call rather than the real result) — presence now
updates in every environment and logs real failures instead of going
silent. - Nil slices (a user's teams, a team's
bots/servers/members, etc.) now
consistently marshal as[]instead ofnullacross list/user/team
endpoints, preventing frontend crashes on.length/.mapagainst a
response that has no data yet. GET /teams/{id}had the same nil-slice gap ontags/extra_links
directly (as opposed to the application-resolved slices above, which were
already covered) — a team with no tags or links set crashed the frontend
team page outright rather than just rendering emptily.webhooks/senderfailed to build at all: an in-progress refactor had
extractedSecret,webhookSendState, and several helper methods
(resolveTarget,buildRequest,notify,markFailed,logFields)
into newrequest.go/sendstate.gofiles, butsender.gostill carried
the old duplicate declarations and its own inline copies of the same
logic.sender.gonow uses the extracted helpers instead of duplicating
them.
Security
- Internal error details (raw Go
error.Error()text) are no longer
included in several API error responses, including session
authentication and a number of other endpoints the underlying error is
still logged server-side in full, but clients now receive a generic
message instead of internal implementation details.