Releases: PlexiOSS/Popplio
Release list
v1.3.1
Added
- Arcadia's
SearchEntityspanel action now supportsPack,Team, and
Userin addition to the existingBot/Serverpreviously any other
target type 501'd. Backs the admin Search page now covering every
entity type and its respective staff actions instead of just bots and
servers. NewPartialPack/PartialTeam/PartialUservariants added to
thePartialEntitywire union (additive existingBot/Server
consumers are unaffected). GET /users/{id}now returnsuser_servers, the servers owned by any
team the user is on (mirroring the existinguser_bots/user_packs
resolution). Servers have no directownercolumn team ownership is
the only path, same asGetOwnedBy's server branch. Public user
profiles previously had no way to show a user's servers at all.- The staff bot gained guild moderation commands
/kick,/ban,
/timeout, and/warn(newmoderate_guild/warn_userspermissions),
plus self-serve/kb,/ticket, and/staffinfofor pointing users at
the Knowledge Base, ticket support, and the staff hierarchy without
retyping the same links. Every moderation command refuses to act on a
target who is themselves staff at a rank equal to or more senior than
the caller's own (perms.LoadStaff(...).Rank())staff_positionsis
the same hierarchy Discord role assignments already sync into via
StaffResync, so this is one hierarchy check, not a separate Discord
one and a separate Omniplex one.
Fixed
- Two Discord embeds in
routes/staff/endpoints/manage_app/route.go
("Application Approved"/"Application Denied") still linked to the
deprecated SvelteKit panel (Sites.Panel+/panel/apps) after the
equivalent submission-time embed was already fixed to point at
Omniplex's/admin/applications— these two were missed in that pass. StaffResync(arcadia/tasks/staffresync.go) inserted a new row into
staff_membersbefore checking whether that user had ausersrow yet.
staff_members.user_idhas a foreign key intousers, so any staff
member who held a staff role in Discord but had never actually logged
into Omniplex made every resync run fail with astaff_members_user_id_fkey
violation, repeating on every scheduled run until fixed. The
ensure-usersrow exists check now runs before thestaff_members
insert/update instead of after it.
v1.3.0
Added
- Certification approval (
reviewLogicCert/reviewLogicCertServerin
apps/logic.go) now automatically grantsBotDeveloper/
CertifiedDeveloperroles to a certified bot's owner(s), or every
member of a certified server's owning team, provided they're already in
the main guild — previously only the bot's ownCertBotrole was
granted, and owners had to know to runibb!getbotrolesthemselves. GET /list/statsgainedtotal_banned_usersandtotal_vote_banned_bots,
aggregateCOUNT(*)queries over the existingbanned/vote_banned
columns, for the Moderation Transparency page's new "Platform safety"
section. Public, no PII — same pattern as the existing report-stats
endpoint.
Fixed
- A Discord API failure while granting a bot's own
CertBotrole during
certification (e.g. the bot not currently being in the server) used to
hard-fail the entire review, leaving the application stuck "pending"
even thoughbots.typehad already been committed ascertified
separately. It's now logged as a warning instead of aborting the review. GetOwnedBy(arcadia/impls/entities.go) only checked team ownership
for bots, silently missing bots owned directly — meaning a direct owner
got "you don't own any bots" from/getbotroleseven when they
genuinely did. Added the missingOR owner = $1branch./staff/tickets?open=truewas 500ing: 8 legacy ticket rows had
messagesstored as a JSON object instead of an array, and
pgx.RowToStructByNamefails the whole result-set scan on a single
row's type mismatch. Normalized the affected rows;create_ticket
already always writes a real array, so this was legacy data, not a
recurring bug.
v1.2.2
Fixed
arcadia/panel/ops_proxy.go'spopplioStaffproxy (backs the new
Applications admin page) rejected every request with a misleading
"Path must start with /" error in production, even for a
correctly-formed path like/staff/apps. Root cause was in
safeJoinPopplio(arcadia/panel/paths.go): beyond the real security
boundary (same scheme+host as Popplio's own API base), it also enforced
that the resolved path stay under the same path prefix as the
configured base URL which rejects any legitimate root-level target
whenever that base URL has a non-root path component. That check added
no security beyond the origin check and only broke valid callers, so
it's removed. Also stopped collapsing everysafeJoinPopplioerror into
the same fixed string the real error now surfaces, so a future failure
here is diagnosable instead of misleading.notifications.PushNotification'sNoSavefield was inverted from its
own name/doc comment (if notif.NoSave { INSERT }persisted only when
told not to save). In effect, every "normal" alert (push-subscribe
confirmation, reminder-set confirmation, payment-failure alerts) never
reached a user's in-app alert inbox, only ever firing as a transient
push notification the one caller that explicitly opted out of saving
(vote_reminders.go, "spammy, fills up the db quickly") was the only
alert type that persisted. Condition is nowif !notif.NoSave, matching
what the field has always been named and documented to mean.- The "New Application" Discord embed (
routes/apps/endpoints/create_app)
linked to the old SvelteKit panel (Sites.Panel+/panel/apps),
superseded by Omniplex's own/admin/applicationslink updated.
Added
GET /staff/ticketsevery ticket platform-wide, gated on the existing
view_ticketsstaff permission (optional?open=true|falsefilter,
paginated). Staff could already view/reply/close/reopen any ticket via
the existing owner-or-staff checks onget_ticket/
create_ticket_message/patch_ticket, but had no way to find a ticket
ID to act on in the first place this closes that gap. Auth follows the
same normal-user-session + in-handler permission check as the other
ticket routes, not the legacystaffpanel__authchainsystem the
Applications page uses.- A user-facing confirmation alert (now that
PushNotificationactually
persists them) at three points that previously gave zero in-app
feedback on success a purchase completing (GivePerks, alongside the
existing staff-only mod-log post), a shop item purchase, and a vote
credit redemption. All three are best-effort: the underlying change is
already committed by the time the alert is sent, so a failed alert logs
a warning rather than turning into an error response for something that
actually succeeded. - A new report being filed now posts to the staff-only
StaffLogs
Discord channel (target, type, reason no reporter identity, consistent
with reporter identity being staff-panel-only everywhere else). The
equivalent "new application submitted" post already existed
(create_appposts to theAppschannel with an@Appsrole ping) confirmed by reading the handler directly, not assumed.
v1.2.1
Changed
- The Friday-Sunday double-vote weekend bonus (
votes.GetDoubleVote) now
pins its day-of-week check to UTC explicitly instead of relying on
time.Now()'s implicit host-local timezone, so the boundary is the same
instant regardless of what timezone the process happens to run in. Also
addedVoteInfo.WeekendBonus, a per-entity flag reporting whether the
bonus is actively boosting that entity'sper_user/vote_timeright
now —falsefor premium bots/servers even on a bonus weekend, since
their flat premium cooldown already applies instead. Nothing in the API
previously told callers when the bonus was live. - Certification requirements loosened and diversified. The old rule
required a bot to clear servers ≥100 and unique clicks ≥30 both,
no exceptions. It's now an OR across three lowered bars (servers ≥50,
unique clicks ≥15, or votes ≥50, the last one is new), plus a new but
lenient 3-day minimum listed age. A bot excelling in one metric no
longer gets rejected for not excelling in all of them. - Servers can now be certified too a new "Server Certification"
position on/apps(extraLogicCertServer/reviewLogicCertServerin
apps/logic.go), using the same OR-of-three-metrics rule scaled to
server stats (members ≥100 in place of bot servers ≥50). New
request_server_certificationpermission. The "Certified" badge
Omniplex has always been able to render for servers had no backend path
that ever set it until now. - Premium and Shop now work for servers, not just bots:
CreatePerkData/PerkDatagained afor_typefield ("bot"or
"server", defaults to"bot"if omitted) so Stripe/PayPal checkout
and the booster-offer redemption can target either.
servers.premiumhas been a real, displayed column with no purchase
path behind it since it was added; now there is one.- Shop purchases (
POST /{target_type}/{target_id}/shop/purchase) and
all five benefit effects (routes/shop/assets/benefits.go) now
branch on target type betweenbots/serversboth tables carry
identical benefit columns. serversgained the sameboosted_until/featured_until/
supporter_badge/vote_blitz_untilcolumns bots already had
(exp/serverbenefits.sql), plus the matching read-side effects:
boosted-first sort inGET /servers/@all, afeaturedcategory in
GET /servers/@index, and a vote-blitz cooldown halving in
EntityVoteInfo's"server"case.
Added
GET /list/statsnow includestotal_pending_botsand
total_denied_bots, so consumers can show a real approved/certified/
pending/denied breakdown instead of inferring it fromtotal_botsminus
the listed count.GET /staff/shop-purchasesthe same shop-purchase data
GET /{target_type}/{target_id}/shop/purchasesalready exposes
publicly one entity at a time, but platform-wide and staff-gated
(view_shop) for abuse/fraud monitoring. No frontend consumes this yet
since the Arcadia panel UI isn't part of this repo it's ready for
whenever that side wires it up.- A standalone support ticket system. The existing
ticketstable/Ticket
type were entirely Discord-channel-shaped (channel_id,enc_key) with
no creation path anywhere in the codebase, not in the API, not in the
Discord bot nothing has ever created a ticket in this codebase. Rather
than build the Discord-integration side, tickets are now a plain web
feature reusing the same table (channel_idleft"",enc_keyleft
null):GET /tickets/topics(a small hardcoded topic catalogue, same
convention asapps.Apps),POST/GET /users/{id}/tickets,
POST /tickets/{id}/messages, andPATCH /tickets/{id}to close/reopen
(closing is open to the author or staff; reopening is staff-only, via a
newmanage_ticketspermission). New message IDs are synthesized
Discord-format snowflakes (disgoorg/snowflake'sNew) purely so
GET /tickets/{id}'s existingsnowflake.Parse-based timestamp
decoding keeps working unchanged for old and new tickets alike. - Shop purchases actually do something now.
shop_items/shop_item_benefits
have had full staff CRUD via the Arcadia panel for a while, but nothing
ever spent an entity's earned vote credits on one or defined what a
benefit's effect even was. New:POST /{target_type}/{target_id}/shop/purchase(bots only for now,
gated on a newbuy_shop_itemsentity permission) spends credits
oldest-batch-first acrossentity_vote_redeem_logs, logs the purchase
to a newshop_purchasestable, and applies every benefit ID on the
item that Popplio recognizes.- Five recognized benefit IDs, each with a real effect:
premium_days(extends the bot's premium period, identical to the
Stripe/PayPal path),priority_boost(newboosted_untilcolumn,
sorts first in/bots/@all's default order while active),
featured_slot(newfeatured_untilcolumn, surfaces the bot in a
newfeaturedcategory on/bots/@index),supporter_badge(new
permanentsupporter_badgeflag), andvote_blitz(new
vote_blitz_untilcolumn, halvesEntityVoteInfo's vote-time
cooldown while active). Unrecognized benefit IDs no-op rather than
error, so staff can still catalogue purely descriptive/future
benefits without breaking a purchase but an item with zero
recognized benefits is rejected at purchase time rather than silently
spending credits for nothing. GET /{target_type}/{target_id}/shop/purchasespurchase history,
public, same transparency level as the existing vote-credit logs.exp/shopbenefits.sql(schema: 4 newbotscolumns + the
shop_purchasestable) andexp/shopbenefits_seed.sql(optional
starter catalog rows for the 5 benefits) the seed is just a
starting point; the same rows can be created through the Arcadia
panel instead.
Changed
- Omniplex is now owned by NodeByte LTD. Remaining "Infinity Bot List" /
"Infinity Development" copy left over from the old brand — application
question text, the staff-denial DM, webhook docs, the RSS feed title
and copyright line, the auth-log embed footer, and the!delete
bot-command copy now reads "Omniplex" / "NodeByte LTD".
Fixed
- The Gold premium plan granted ~365 hours (~15 days) of premium instead
of a yearTimePeriodwas set in raw days whileGivePerksapplies
it as hours. Bronze/Silver were already correct; Gold now multiplies by
24 like they do. POST /users/{id}/redeem-payment-offer?code=BOOSTPREMIUMgranted the
perk successfully but then always fell through to a final400 Invalid offer coderesponse regardless — no caller could ever see it succeed.
It also never stampedlast_booster_claim, so the "once every 30 days"
cooldown could never actually engage. Both are fixed: a successful
redemption now returns204and updates the claim timestamp.tickets.user_idhad no foreign key constraint tousers(user_id)at
all, just a plain column — so the account data-export/deletion pipeline
(POST /users/{id}/data,routes/users/endpoints/create_data_task)
silently skipped every ticket a user had ever filed. The walker
(ddr_task.go) auto-includes any table with a real FK into an
already-registered root (users/teams), so the fix is schema-only:
exp/ticketuserfkey.sqladds the constraintNOT VALID(4 legacy
tickets reference since-deleted accounts;NOT VALIDenforces it for
all new/updated rows without deleting or nulling that history). No Go
changes needed — confirmed via a directpg_constraintcheck against
the dev DB that tickets are now walked correctly.
v1.2.0
Added
- Packs are no longer bots-only: a new
pack_typecolumn (bot|server
|emoji, immutable after creation) generalizes the existingBotPack
type, and a newpack_emojistable backs a genuinely new capability —
user-curated emoji packs, each emoji its own durably-uploaded asset (not
a live reference into a server's synced emoji list, so a pack keeps
working even if the source server stops syncing or leaves). Server packs
reuse theServers []stringfield that already existed onBotPackbut
was never wired to any route or UI.add_pack/patch_packvalidate
content per type (bot packs needbots, server packs needservers,
emoji packs needemojis, capped at 50),get_all_packsgained an
optional?pack_type=filter, and a newedit_packsentity permission
(teams.GetEntityPerms's new"pack"case, single-owner only — no team
fallback) lets the existing generic upload-permission-check flow cover
pack emoji uploads the same way it already covers bot/server banners. - A generic content-report system (
popplio/reports, newroutes/reports
package), built alongside the pack generalization above to give users a
way to flag a pack (or, later, any votable entity) for e.g. a license
violation on an emoji pack.PUT /users/{uid}/{target_type}/{target_id}/reports
mirrors the votes router's exact URL shape and target-type handling.
Reports are keyed(target_type, target_id), same convention as
entity_votes; a partial unique index allows only one open report per
reporter per target, and a per-user daily cap (10) limits spamming many
different targets. Reporter identity is never exposed outside the staff
panel — the public API never returns it. Reviewed exclusively through a
new Arcadia RPC (UpdateReports/ReportAction, following
PartnerAction's exact discriminated-union codec pattern) gated on a new
review_reportsstaff permission; there is deliberately no public
listing/review route, matching how Blog/Partners never got one either.
Config/DB note: three new one-off migrations to apply —
exp/packtype.sql,exp/packemojis.sql,exp/reports.sql. GET /bots/@allandGET /servers/@allgained an optional
?sort=trendingparam, ranking by net votes (upvotes minus downvotes) in
the last 7 days instead of newest-first, and returning only entities with
at least one vote in that window. New composite index
entity_votes_target_created_idx(exp/entityvotesidx.sql) backs the
underlying grouped query —entity_voteshad no index at all before
this, so trending would otherwise have been a full table scan.GET /reports/stats: a new, deliberate exception to the reports
system's "no public read-back" design — anonymized counts of reports
grouped byreason/statusonly (no report IDs, no target identity, no
reporter identity), for a public moderation-transparency page.GET /servers/@emojis: a new paginated endpoint returning only
server_id/name/avatar/emojis/stickersfor servers with
show_emojis = true.IndexServer(what@allreturns) excludes
emoji/sticker data entirely, so a cross-server emoji/sticker browse page
had no way to bulk-fetch this without N+1 calls toGET /servers/{id}
before this.
v1.1.0
Added
- Infernoplex the standalone Rust Discord server-tracking bot has been
ported into Popplio's own binary as a newinfernoplex/package, the same
treatment Arcadia got earlier.main.gonow starts it right alongside
Arcadia (infernoplex.Start(state.Context), stopped with the same 30s
grace period on shutdown) instead of it running as a separate service.
The port covers everything the Rust bot did: a guided multi-step server
setup wizard (infernoplex/bot/setup.go), invite creation/resolution
(infernoplex/invite), a server-info push command gated on "Edit
Servers" (cmdUpdate), a vote leaderboard (cmdLeaderboard), a
bot-stats command (cmdStatsversion/Go version/git commit/env, mirrors
Arcadia's/info), and background tasks for server/emoji/sticker sync and
team-member cleanup (infernoplex/tasks). It also runs its own small
internal HTTP API, "Sorbet" (infernoplex/sorbet), structured the same
way as Arcadia's panel dispatch. The standalone Rust Infernoplex service
is superseded by this and should be decommissioned.
Config shape change (updateconfig.yamlbefore deploying): a new
infernoplex:block withclient_id/client_secretplus per-environment
prefix/server_port/token(sameDiffers[T]staging/prod/beta/dev
pattern used elsewhere) — seeconfig.yaml.sample. - Infernoplex's leaderboard command now replies with a "No Votes Yet" embed
instead of an empty/broken one when a server has zero votes. - Self-hosted proof-of-work vote captcha (
popplio/captcha), replacing the
deadHCaptchaInfoscaffolding intypes/vote.go(which was never wired
to anything) with something actually enforced.GET /votes/captcha/challengeissues a signed, stateless hashcash-style
challenge (find a nonce sosha256(salt+":"+nonce)has N leading zero
bits);PUT .../votesnow requires a solved challenge in the request body
for bot/server votes unless the entity has opted out via the existing
captcha_opt_outsetting. Challenges are HMAC-signed with the new
captcha.hmac_secretconfig value so they can't be forged, and each
solved challenge is single-use (consumed in Redis on first successful
verification) so a solve can't be replayed across multiple votes. No
third-party captcha provider involved — the whole protocol lives in
popplio/captcha.
Config shape change (updateconfig.yamlbefore deploying): a new
captcha:block with a per-environmenthmac_secret(sameDiffers[T]
pattern used elsewhere) — seeconfig.yaml.sample. Generate one with e.g.
openssl rand -hex 32; rotating it invalidates all outstanding
challenges.
Changed
- Rebranded "Infinity List" → "Omniplex" across every remaining user-facing
string that still had the old name: the MFA issuer shown in a staff
member's authenticator app on re-enrollment (arcadia/panel/mfa.go), the
staff bot's/analyticsembed title (and its frozen conformance string),
and the fallback SEO description onGET .../teams/{id}/seowhen a team
has no custom short description ("View the team X on Omniplex"). A few
doc comments got the same treatment with no functional effect. - Every reply the staff bot makes is now an embed, including one-liners
(the "Isabelle" rewrite, #43). A bare content message is indistinguishable
from a staff member talking, which matters in the staff server where the
bot's answers and the conversation share a channel.Ctx.Saybuilds the
embed itself, so this is a change of container rather than of wording —
every string frozen inarcadia/conformanceis untouched and still
asserted. Two coloured variants went in alongside it:Ctx.Fail(red) for
the command guards, the panic handler and the "there was an error" paths,
andCtx.Ok(green) for the 16 replies that report something having
worked, so a refusal is visibly different from an answer without either
having to say so. The modal driver and the permission editor's ephemeral
refusals, which answer through the interaction rather than throughCtx,
build the same shape by hand (modalReplyin
arcadia/bot/interactions.go).TestRepliesAreEmbedswalks the package's
AST for anyMessageCreatethat setsContentand fails if one appears. - A second pass over the same files, this time pulling out the repetition
rather than only moving it. Inarcadia/rpc:modLogReasonbuilds the
mod-log embed nine handlers were each building by hand (title,
description, one Reason field, footer, colour),reasonFieldcovers the
four multi-field embeds that keep their own shape, and
guardBot/guardUserreplace the ten copies of "reject an over-long
reason, then check the target exists".certifyAddwent from 47 lines to
20 this way, andreview.gosplit intoclaim.goandverdict.goonce
it had. Inarcadia/panel:authorizereplaces the ten copies of the
twelve-linecheckAuth+resolvedPermspreamble, andops_core.go
(608) split intoops_auth,ops_hello,ops_queue,ops_rpc,
ops_searchandops_proxy.arcadia/tasks/staffresync.go(579) split
into the resync itself, its reporting and its Discord role mirroring.
What was deliberately not factored out: the frozen embed and error
strings stay written out at their call sites, because
arcadia/conformancefinds them by scanning the source for the literal —
a helper that formatted them would pass its own tests while quietly
removing that check. For the same reason the SQL stays literal at each
call site, sincearcadia/dbconformPREPAREs every string literal it can
find against a real database. And the five steps ofStaffResyncare
left inline: they share a transaction and a working set that each step
narrows, so splitting them would make an ordering that is load-bearing
look optional. - The five files that had grown past the point of being navigable are split
by what they do, with no behaviour change:arcadia/bot/staffroles.go
(1137) intostaffmgmt.go(the role model, the authority rules, the
lookups),staffroles.go,staffperms.goandstaffrender.go;
arcadia/bot/commands.go(697) intocommands.go(the registry and the
two shared RPC helpers) plushelp.go,invites.go,stats.goand
staffops.go;arcadia/panel/ops_shop.go(861) into one file per shop
concern (tiers, items, benefits, coupons, whitelist);
arcadia/panel/ops_staff.go(759) into positions, members and
disciplinaries, with its two shared existence checks moved to
ops_query.gowhere the shop operations that also use them can find
them; andarcadia/bot/permeditor.go(858) into
session/apply/render/util. Each new file opens with what it covers and
what is non-obvious about that area. All of it is code movement verified
line-for-line against the original; nothing in the repo's directory
structure changed, androutes/'s one-package-per-endpoint layout is
left alone sinceuapirequires it. arcadia/rpc/methods.go(878 lines, every RPC action in one file) is
split into one file per group of actions, grouped exactly the way
types.rpcPermissionsgroups them — so the file an action lives in is
the same question as which permission gates it:review.go(claim,
unclaim, approve, deny, unverify),certify.go,transfer.go,
forceremove.go,premium.go,votes.go,apps.go, plusdispatch.go
for the method-to-handler switch andaudit.gofor the
staff_general_logswrite.core.gokeeps theExecutepipeline and
the shared guards, and its package doc now carries the map of where
things live and the note that every mod-log embed is reproduced verbatim
from the Rust original (that note used to sit above the dispatcher and
said "every embed below", which the split would have made a lie). Pure
code movement: every moved line is byte-identical to what it replaced,
andarcadia/conformancescans the whole package rather than one file,
so it pins the embed strings exactly as before. (review.gowas later
split further intoclaim.go/verdict.go— see the dedup-pass bullet
above;arcadia/CONFORMANCE.md's file references still sayreview.go
in a few spots and need updating to match, see Known Issues below.)- The Dev Team staff application no longer requires or mentions Rust
(description and two questions updated to reflect Go/TypeScript only);
the QAQC application track was removed entirely. Consistent with Arcadia
and now Infernoplex both being fully off Rust.
Security
- Only the prod instance now sets the main Discord bot's gateway presence
(state.go'sOnGuildsReadyhandler). Staging/beta/dev instances still
connect and function normally, they just no longer call
SetPresenceForShard, so a non-prod checkout — misconfigured shared
token or otherwise — can never overwrite what the public bot's profile
shows as its "Watching" activity.
Removed
- Five retired permissions —
view_shop,manage_shop,
manage_bot_whitelist,view_cdn,manage_cdnpurged from every
stored permission array (staff_positions.perms,
staff_members.perm_overrides,staff_disciplinary_types.perm_limits)
via a new one-off migration,exp/rewrite/remove_broken_perms.sql
(needs to be applied manually against the database like otherexp/
scripts).
Known issues found during this pass, not yet fixed
- Infernoplex's new "No Votes Yet" message has a typo: "Unfortuently, your
server has no votes at this time." config/config.go'sNaevisstruct (added alongsideInfernoplexas an
apparent placeholder for a second bot) is dead code it's never
referenced from the top-levelConfigstruct despite its fields being
taggedvalidate:"required", andconfig.yaml.sample's `n...
Staff Tooling Update & Bug Fixes
Changed
- Every
Differs[T]config key (DB tokens, site URLs, etc.) previously
required both astagingand aprodvalue to be set regardless of
which environment a given box actually runs — acurrent-env: prodbox
was rejected at startup for a missingstagingvalue it would never
read, and vice versa.ValidateDiffersnow only requires whichever value
Parse()will actually resolve forCurrentEnv(prodneedsprod,
stagingneedsstaging;beta/devstill accept either their own
value or astagingfallback, unchanged), so a config file only needs to
fill in what the box it's deployed to actually uses. - Staff roles and permissions can now be managed interactively from the staff
bot:/staffroles edit [role]and/staffperms edit <user>open a select
menu editor (arcadia/bot/permeditor.go) with a role picker, a category
picker and a multi-select of that category's permissions, preselected to
what the role or member currently holds — ticking grants, unticking revokes,
and everything outside the open category is carried through untouched.
Dangerous permissions are marked⚠️ and ones the caller cannot manage 🔒.
The existing one-at-a-timegrant/revokesubcommands are unchanged and
still work; both paths share the same rank check andperms.CheckPatch
rule, and the editor re-checks both at the moment of the write rather than
only when it opened, since a session lives for ten minutes. Every render
reloads the target from the database, so two people editing the same role
see each other's changes instead of saving a stale picture over them.
Alongside the menus are buttons: "Grant all"/"Revoke all" for the open
category (which leave permissions the caller cannot manage exactly as they
are, so one locked permission doesn't make the button useless), "Pick
another role" to switch targets without closing, and "Close".editis
registered as the first subcommand of both commands, since Discord lists
them in registration order and never lets the parent command
(/staffroleson its own) be invoked at all.
Fixed
DELETE /teams/{tid}/members/{mid}had its last-owner safety check
inverted (introduced by the kittycat→internalpermspackage refactor):
it fired when the member being removed was not an owner instead of when
they were, so removing any regular member from a team with only one owner
(the common case) 400'd with "There needs to be one other global owner
before you can remove yourself from owner" — while actually removing the
team's last real owner sailed through with no check at all, the exact
case this was meant to prevent. Condition un-inverted.- Every staff bot slash command appeared twice in every server. The bot
registers its commands per guild (arcadia/bot.SyncCommands), but the
application still carried global registrations of the same commands from an
earlier deployment, and Discord lists a global command alongside a guild
command of the same name rather than letting the guild copy take its place.
SyncCommandsnow finishes by deleting the global registration of any
command it registers per guild (pruneGlobalCommands), so the duplicates
clear themselves on the next sync (startup, or/register). Global
commands whose names the bot does not register are left alone and only
logged as a warning, since they belong to something else sharing the
application. - The
server/teamauth types were never registered as OpenAPI security
schemes (onlyUser/Botwere, viadocs.AddSecuritySchemain
main.go) even thoughAuthorize()has always fully supported them —
every one of the 41 operations requiringserverorteamauth
(PUT /bots,PUT /servers, bothPATCH .../settingsendpoints,
reviews, sessions, etc.) referenced a security scheme name absent from
components.securitySchemes. Harmless to the API itself, but any tool
that resolves the requirement against registered schemes crashes outright
on the unresolved reference — including the docs site's OpenAPI reference
pages (fumadocs-openapi'sAPIPage, which throws
Cannot read properties of undefined (reading 'type')). Registered both
(docs.AddSecuritySchema("server", ...)/("team", ...), lowercase to
matchAuthTypeMap's self-mapping for these two types). - Presence still never actually got set even after 1.0.0's fix, now logging
error while setting presence err="no gateway configured"from inside
OnGuildsReadyinstead of right on startup — that fix only addressed the
timing, not the actual cause: Popplio runs sharded (OpenShardManager),
andDiscord.SetPresenceonly ever checks disgo's single-gateway field
(populated byOpenGateway, notOpenShardManager), so it returns
ErrNoGatewayunconditionally on a sharded bot regardless of readiness.
OnGuildsReadyalso fires once per shard, not once globally. Now uses
Discord.SetPresenceForShard(ctx, event.ShardID(), ...)instead. POST /auth/test("Test Auth") 500ed on every call that reached an actual
authorization check —api.AuthorizereadsPERMISSION_CHECK_KEYout of
the route'sExtDataunconditionally, but the syntheticuapi.Route{}
this endpoint builds to call it never setExtDataat all, so any request
with a syntactically valid token failed with a 500
(permissionCheck not found in route.ExtData) instead of returning
whether the token is actually valid. Only requests with a token that
failed even earlier (nonexistent inapi_sessions) ever got a real
response (401). Now sets a no-opPermissionCheck(NeededPermission
always returnsnil), since this endpoint has no permission model of its
own to enforce — it's purely "is this token valid for this target."
Removed
- The
use_borealisstaff permission. Borealis was removed from the platform
during the port (arcadia/CONFORMANCE.mdD11a — thearcadia.borealis_url
config key, the client and theApprovecall to it are all long gone), so
the permission has gated nothing since and only added a line to
/permissionsand a row to every permission picker.exp/rewrite/flatperms.sql
now lists the oldborealis.*inretired_perm(dropped on purpose)
instead of mapping it ontouse_borealis, and
exp/rewrite/remove_borealis_perm.sqlstrips it from
staff_positions.perms,staff_members.perm_overridesand
staff_disciplinary_types.perm_limitsfor databases the old migration
already ran against. That cleanup is needed rather than cosmetic: the
permission model deliberately keeps names it does not declare, since they
may belong to another service, souse_borealiswould otherwise sit in
those columns for good and show up under "Other services".
Security
- Bot accounts can no longer hold staff permissions at all — not through a
staff role, not through a direct grant, and not througharcadia.owners
(perms.ErrBotAccount). Previously nothing stopped one:StaffResync
walks every member of the staff server and creates astaff_membersrow
for anyone holding a position's Discord role, and it never looked at
whether that member was a bot, so giving a bot a staff role in Discord
handed it that role's permissions — including through the panel session
and RPC paths, which only ever asked what the row said. A bot is a token
that can be handed to another program, which is exactly what the staff
model's accountability assumes cannot happen, and nothing needs it: the
staff bot and the panel both act under a staff member's identity, never
their own. Enforced on both sides:- Reads:
perms.StaffGrantscarries aBotAccountflag, joined in from
dovewing's user cache byLoadStaffat no extra cost, andResolve()
returns nothing andRank()returnsNoRankwhen it is set. The panel's
session check (impls.CheckAuthInsecure), its login
(ops_authorize.go) and its member view (impls.GetStaffMember, whose
additory disciplinaries could otherwise add permissions on top of an
empty set) all apply the same rule. These paths stay database-only, so
they keep working when Discord does not. - Writes:
perms.RejectBotAccountresolves through dovewing all the way
to Discord if the account has never been seen, and fails closed if it
cannot tell.StaffResyncnow skips bot members entirely, which also
means an existing bot's staff row is cleaned up by the same pass that
handles members who left; the panel'seditMemberand the staff bot's
/staffperms grant/revoke/editrefuse a bot target outright.
- Reads:
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 man...