Omniplex

Changelog

Release notes for API and Website, pulled straight from GitHub.

RSS
Website

v0.2.0

v0.2.02h ago

Added

  • Admin Search (/admin/search) now covers every entity type bots,
    servers, packs, teams, and users instead of just bots and servers,
    each with its own Actions menu (backed by Popplio's newly-extended
    SearchEntitys).
  • A /staff knowledge base section (redirects to a new staff KB
    category) covering the real staff hierarchy, the full permissions
    reference, how reports/tickets/applications actually reach staff, and
    staff conduct/transparency all sourced directly from the live
    staff_positions table and Popplio's permission catalogue, not
    guessed. Linked from the footer.
  • Public user profiles (/user/[id]) now show a user's servers and every
    public link they've added, not just Bots and a hardcoded website/GitHub
    pair. New shared TeamCard component (also now used on the dashboard's
    Teams tab, replacing a near-identical inline copy) so Teams can be
    shown too. The "hasn't listed anything" empty state now checks bots,
    servers, packs, and teams together instead of just bots.
  • Report detail (/admin/reports) now shows the actual bot/server/pack/team
    the report is about — icon, name, and a link straight to its listing —
    instead of just the raw target type/id. An "Actions" button next to it
    loads Arcadia's staff RPC methods filtered to whatever that target type
    actually supports (e.g. force-removal for bots) via the new, reusable
    GenericRpcModal, so resolving a report and actually acting on the
    reported content no longer requires leaving the modal.
  • "Platform safety" stats (banned users, vote-banned bots) on the
    Moderation Transparency page (/about/moderation), backed by Popplio's
    extended GET /list/stats. Styled as its own card-grid section,
    matching the existing "Bot review pipeline" section the reports table
    itself is unchanged.
  • A "Support Tickets" check on the Status page (/about/status), backed
    by the public GET /tickets/topics endpoint.

Fixed

  • Team/server/bot avatar and banner updates weren't reflecting instantly
    after upload — Next's image optimizer was caching the old asset
    in front of already-correct origin cache headers. Avatar/Banner now
    render with unoptimized, since every source is already our own CDN
    proxy or Discord's hash-versioned CDN.
  • The dashboard's profile editor (EditProfileTab) had its own,
    slightly-different links list (different remove icon, different "Add
    link" button style) instead of the shared LinksEditor component every
    other edit form already uses — now reuses it.

Changed

  • Admin panel nav decluttered: Queue, Applications, Reports, and Tickets
    are now grouped under a single "Moderation" dropdown instead of four
    separate top-level links.
  • Dashboard bot/server cards moved the "Upgrade" and "Shop" actions into
    the existing "..." menu, keeping only View/Edit inline, to cut down on
    how crowded each card had gotten.
  • API Tokens tab (TokenManager) now puts the "Create Token" button and
    count at the top, with the create form appearing directly below it
    instead of at the bottom of the list; button variant matched to the
    secondary convention used by Teams/Packs/Applications.
  • Security tab cards given filled backgrounds and the same heading
    weight/size as the rest of the dashboard, and laid out side-by-side on
    wide screens instead of stacked in a narrow column.
  • Consistency pass across the admin panel: list spacing, card padding, and
    hover treatment brought in line across Applications, Queue, and Tickets;
    removed the deprecated, permanently-zero "Changelogs" stat from the
    admin Overview page; Dashboard Overview's stat tiles restyled to match
    the admin panel's icon+label convention.
API

v1.3.1

v1.3.12h ago

Added

  • Arcadia's SearchEntitys panel action now supports Pack, Team, and
    User in addition to the existing Bot/Server previously 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. New PartialPack/PartialTeam/PartialUser variants added to
    the PartialEntity wire union (additive existing Bot/Server
    consumers are unaffected).
  • GET /users/{id} now returns user_servers, the servers owned by any
    team the user is on (mirroring the existing user_bots/user_packs
    resolution). Servers have no direct owner column team ownership is
    the only path, same as GetOwnedBy'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 (new moderate_guild/warn_users permissions),
    plus self-serve /kb, /ticket, and /staffinfo for 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_positions is
    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_members before checking whether that user had a users row yet.
    staff_members.user_id has a foreign key into users, so any staff
    member who held a staff role in Discord but had never actually logged
    into Omniplex made every resync run fail with a staff_members_user_id_fkey
    violation, repeating on every scheduled run until fixed. The
    ensure-users row exists check now runs before the staff_members
    insert/update instead of after it.
API

v1.3.0

v1.3.03h ago

Added

  • Certification approval (reviewLogicCert/reviewLogicCertServer in
    apps/logic.go) now automatically grants BotDeveloper/
    CertifiedDeveloper roles 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 own CertBot role was
    granted, and owners had to know to run ibb!getbotroles themselves.
  • GET /list/stats gained total_banned_users and total_vote_banned_bots,
    aggregate COUNT(*) queries over the existing banned/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 CertBot role 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 though bots.type had already been committed as certified
    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 /getbotroles even when they
    genuinely did. Added the missing OR owner = $1 branch.
  • /staff/tickets?open=true was 500ing: 8 legacy ticket rows had
    messages stored as a JSON object instead of an array, and
    pgx.RowToStructByName fails 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.
Website

v0.1.4

v0.1.49h ago

Fixed

  • The Exit Panel button was desktop-only (hidden md:block) and, on
    mobile, only reachable by opening the hamburger menu — not a persistent,
    obvious way back to the main site from anywhere in the admin panel.
    Replaced with an always-visible icon button in the header's icon row
    (same treatment as Customize/notifications/theme), on both desktop and
    mobile.

Added

  • /admin/tickets — a staff ticket queue, filterable by open/closed. The
    API and even a working staff-capable thread view already existed
    (/tickets/{id} already grants any staff member with view_tickets
    view/reply access, and manage_tickets for reopening — confirmed by
    reading get_ticket's existing owner-or-staff check), so this just adds
    the missing "find a ticket to act on" step: list view backed by
    Popplio's new GET /staff/tickets, each row linking straight into the
    existing /tickets/{id} page rather than a new detail view.
API

v1.2.2

v1.2.29h ago

Fixed

  • arcadia/panel/ops_proxy.go's popplioStaff proxy (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 every safeJoinPopplio error into
    the same fixed string the real error now surfaces, so a future failure
    here is diagnosable instead of misleading.
  • notifications.PushNotification's NoSave field 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 now if !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/applications link updated.

Added

  • GET /staff/tickets every ticket platform-wide, gated on the existing
    view_tickets staff permission (optional ?open=true|false filter,
    paginated). Staff could already view/reply/close/reopen any ticket via
    the existing owner-or-staff checks on get_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 legacy staffpanel__authchain system the
    Applications page uses.
  • A user-facing confirmation alert (now that PushNotification actually
    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_app posts to the Apps channel with an @Apps role ping) confirmed by reading the handler directly, not assumed.
API

v1.2.1

v1.2.110h ago

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
    added VoteInfo.WeekendBonus, a per-entity flag reporting whether the
    bonus is actively boosting that entity's per_user/vote_time right
    now — false for 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/reviewLogicCertServer in
    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_certification permission. 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/PerkData gained a for_type field ("bot" or
      "server", defaults to "bot" if omitted) so Stripe/PayPal checkout
      and the booster-offer redemption can target either.
      servers.premium has 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 between bots/servers both tables carry
      identical benefit columns.
    • servers gained the same boosted_until/featured_until/
      supporter_badge/vote_blitz_until columns bots already had
      (exp/serverbenefits.sql), plus the matching read-side effects:
      boosted-first sort in GET /servers/@all, a featured category in
      GET /servers/@index, and a vote-blitz cooldown halving in
      EntityVoteInfo's "server" case.

Added

  • GET /list/stats now includes total_pending_bots and
    total_denied_bots, so consumers can show a real approved/certified/
    pending/denied breakdown instead of inferring it from total_bots minus
    the listed count.
  • GET /staff/shop-purchases the same shop-purchase data
    GET /{target_type}/{target_id}/shop/purchases already 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 tickets table/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_id left "", enc_key left
    null): GET /tickets/topics (a small hardcoded topic catalogue, same
    convention as apps.Apps), POST/GET /users/{id}/tickets,
    POST /tickets/{id}/messages, and PATCH /tickets/{id} to close/reopen
    (closing is open to the author or staff; reopening is staff-only, via a
    new manage_tickets permission). New message IDs are synthesized
    Discord-format snowflakes (disgoorg/snowflake's New) purely so
    GET /tickets/{id}'s existing snowflake.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 new buy_shop_items entity permission) spends credits
      oldest-batch-first across entity_vote_redeem_logs, logs the purchase
      to a new shop_purchases table, 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 (new boosted_until column,
      sorts first in /bots/@all's default order while active),
      featured_slot (new featured_until column, surfaces the bot in a
      new featured category on /bots/@index), supporter_badge (new
      permanent supporter_badge flag), and vote_blitz (new
      vote_blitz_until column, halves EntityVoteInfo'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/purchases purchase history,
      public, same transparency level as the existing vote-credit logs.
    • exp/shopbenefits.sql (schema: 4 new bots columns + the
      shop_purchases table) and exp/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 year TimePeriod was set in raw days while GivePerks applies
    it as hours. Bronze/Silver were already correct; Gold now multiplies by
    24 like they do.
  • POST /users/{id}/redeem-payment-offer?code=BOOSTPREMIUM granted the
    perk successfully but then always fell through to a final 400 Invalid offer code response regardless — no caller could ever see it succeed.
    It also never stamped last_booster_claim, so the "once every 30 days"
    cooldown could never actually engage. Both are fixed: a successful
    redemption now returns 204 and updates the claim timestamp.
  • tickets.user_id had no foreign key constraint to users(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.sql adds the constraint NOT VALID (4 legacy
    tickets reference since-deleted accounts; NOT VALID enforces it for
    all new/updated rows without deleting or nulling that history). No Go
    changes needed — confirmed via a direct pg_constraint check against
    the dev DB that tickets are now walked correctly.
Website

v0.1.3

v0.1.310h ago

Added

  • A "Security" tab on the dashboard (src/app/dashboard/SecurityTab.tsx)
    exposing Popplio's data-export/account-deletion pipeline
    (POST /users/{id}/data), which existed server-side with zero frontend
    consumer until now — closes the loop on the Privacy Policy's "Your
    rights" section, which previously only pointed at a support ticket for
    something that can be self-service. Download builds a per-table
    row-count summary plus a client-side JSON download from the completed
    task's output; deletion requires typing your exact username in a modal
    before it's enabled, since it's a real, irreversible
    DELETE FROM users. Both poll GET /users/{id}/tasks/{tid} every 2s
    (task id/key persisted to localStorage so a page refresh mid-poll
    resumes instead of losing the reference), capping at 5 minutes before
    telling the user to check back later rather than erroring.

  • Vanity URL self-management: BotEditModal/ServerEditModal gained a
    "Vanity URL" field wired to PATCH /{target_type}/{target_id}/vanity
    (vanityResource.update), previously a read-only value in the UI
    despite the endpoint existing. Server-side validation errors (taken,
    blacklisted, contains @) are surfaced verbatim rather than
    reimplemented client-side.

  • A "Recent voters" section (src/components/votes/VoterList.tsx) on bot
    and server pages, backed by the public
    GET /{target_type}/{target_id}/votes/user-list endpoint (bare Discord
    snowflakes, no auth needed) which had no frontend consumer before this.
    Resolves the first 12 voters to a username/avatar via users.getUser
    (Promise.allSettled, falls back to the raw ID if resolution fails for
    any one voter) with a "+N more" tail count from a real paginated total.

  • A staff "Applications" review page (/admin/applications) for the 7
    positions registered in Popplio's /apps system (certification,
    partnership, server certification, staff, ban appeals, etc.) — the
    PATCH /staff/apps/{id} approve/deny endpoint had no staff-facing UI
    anywhere before this, not even in Arcadia as far as this workspace can
    tell. Required adding a new Arcadia RPC wrapper,
    arcadia.popplioStaff() (src/lib/arcadia/client.ts), mirroring
    Popplio's own popplioStaff proxy action
    (popplio/arcadia/panel/ops_proxy.go) that relays a request into
    Popplio's legacy-header-gated /staff/* API and returns its status/body
    verbatim — this wasn't hand-ported into Omniplex's Arcadia client until
    now, so nothing could reach /staff/apps* at all despite the backend
    bridge already existing. Approve/deny is otherwise fully
    server-side-driven (grants/unbans happen inside Popplio's own handler);
    the page only submits {approved, reason} and refetches.

  • A self-hosted Legal hub at /legal, replacing the Footer's links out to
    nodebyte.co.uk/legal/*. Four documents written fresh for what Omniplex
    actually does rather than adapted from the parent brand's general-purpose
    pages: Terms of Service, Privacy Policy, Acceptable Use (the single
    authoritative version of conduct rules that were previously scattered
    across a few KB articles), and a Service Agreement covering premium/shop
    purchases and refunds. Same markdown+frontmatter pattern as the
    Knowledge Base (gray-matter, rendered through the existing Markdown
    component), but flat (src/content/legal/*.md/legal/[slug]) since
    there's no category nesting to model. Two KB articles
    (bots/rules.md, servers/listing-rules.md) already linked to
    /legal/terms as if it existed; that link now resolves instead of
    404ing. Added to the sitemap alongside everything else.

  • The Friday-Sunday double-vote weekend bonus is now actually visible:
    VoteButton/ServerVoteButton show a "Double-vote weekend" banner
    above the vote buttons when it's live and the entity isn't premium
    (premium already gets a flat shorter cooldown instead). The bonus check
    now reads UTC (getUTCDay()) rather than the viewer's local day, to
    match Popplio's now-explicit UTC pinning (see Popplio's changelog) —
    previously the local-time check could disagree with the server near a
    day boundary depending on the viewer's timezone. Voting Rules also now
    states the boundary is UTC, not just "Friday through Sunday".

  • Premium and Shop now work for servers, matching bots (backend change,
    see Popplio's changelog for the full breakdown — servers previously had
    a premium field and a "Certified" badge with no way to actually earn
    either):

    • /premium and /shop both gained a Bot/Server toggle; picking Server
      lists servers from your teams instead of your own bots, and every
      checkout/purchase call now sends the target type through.
    • ServerCard, the server detail page, and the dashboard's server
      cards all get the same Supporter badge / Vote Blitz banner / Upgrade
      • Shop buttons bots already had.
    • /apps gained a "Server Certification" position alongside the
      existing bot one.
  • Certification requirements got more lenient and multi-metric instead of
    a strict two-thresholds-at-once bar (see Popplio's changelog) —
    Certification and Partner Requirements in the Knowledge Base rewritten
    to match, and to note certification now covers servers too.

  • Knowledge Base coverage for everything shipped this cycle that had none:
    three new categories — Account (Alerts & Push Notifications, Vote
    Reminders), Premium & Shop (Premium Plans & Checkout, Vote Credits &
    the Shop), Support (Support Tickets) — plus a new How to Apply
    article in Programs covering the /apps mechanics that Certification
    and Partnership used to each describe informally. Also fixed three
    articles that had gone stale from earlier work this cycle:
    Partnership's "How to apply" still pointed at Discord instead of the
    in-app form, Voting Rules' "Vote credits" section predated the Shop and
    called credits opt-in (they're not), and Getting Started's pack step
    only described bot packs, not the server/emoji pack types packs have
    supported for a while.

  • Home page rebalanced toward servers, which previously only got one
    section (Top Servers) against four bot-only ones:

    • The hero headline now rotates through "bots" / "servers" / "packs"
      (RotatingWord, crossfades in place with no layout shift, extra
      words are a one-line addition later) instead of hardcoding "bots".
    • HomeTabs gained a Bots/Servers toggle alongside its existing
      Top Voted/New/Most Viewed tabs, using server index data
      (servers.getIndex()) that was already being fetched but mostly
      unused.
    • Certified and Premium sections (previously bot-only, with servers'
      certified/premium data fetched but never shown) are now a single
      "Spotlight" block using the same toggle, replacing three separate
      sections with one.
    • The standalone "Top Servers" section was removed since the Bots/
      Servers toggle on the main tabs now covers the same ground.
    • The main tabs gained a "Random" tab backed by Popplio's /bots/@random
      and /servers/@random (both existed already; bots.getRandom() was
      even already written but never called anywhere, and there was no
      servers.getRandom() at all until now). Re-rolls client-side on every
      visit to the tab, with a manual shuffle button to draw again.
    • The home page's "Packs" section (renamed from "Bot Packs", since packs
      have supported bot/server/emoji for a while) and the Featured Bots
      section were showing only 6 cards despite Popplio returning up to
      9/12 — now show 9, matching the Top Voted/Certified/Premium tabs,
      which were already at 9 on both ends.
  • Moderation Transparency page now shows a bot review pipeline (Approved /
    Certified / Awaiting Review / Denied) alongside the existing report
    counts. Popplio's /list/stats gained total_pending_bots and
    total_denied_bots to back it — everything else on the page was already
    public data, this just closes the last gap.

  • The "Bots" stat on the home page and the "Listed Bots" / "Total
    Submitted" stats on the About page now consistently read total_bots
    every bot ever submitted, not just the approved ones — so the same
    number shows everywhere instead of quietly differing by page.

  • Applications — staff, dev team, partnerships, and certification have had
    a full submit-and-review pipeline in Popplio that nothing in Omniplex
    ever surfaced. New frontend-only surface:

    • /apps lists open positions pulled live from Popplio (tags, a short
      teaser, closed/open state).
    • /apps/[id] renders each position's full description and a form built
      dynamically from its question set (short answers vs. long-form,
      matching the backend's own length rules so validation errors are rare
      by the time it hits the server).
    • A new "Applications" tab on the dashboard lists everything a user has
      submitted, with state (Pending/Approved/Denied) and any staff feedback
      once reviewed.
  • Premium Popplio's Stripe/PayPal checkout and booster-offer redemption
    have been fully wired backend-side with nothing in Omniplex to start a
    purchase. New:

    • /premium lists the Bronze/Silver/Gold plans, lets you pick one of
      your own approved/certified, not-yet-premium bots, and pay with card
      (Stripe Checkout) or PayPal — both just redirect to the provider's
      hosted checkout, no card data ever touches Omniplex. Server boosters
      get an extra "redeem free" option on the Bronze plan.
    • /payments/success and /payments/cancelled — Popplio's Stripe and
      PayPal flows redirect back to these by hardcoded URL, so they had to
      exist for checkout to complete at all.
    • An "Upgrade" button on eligible bots in the dashboard's Bots tab links
      straight into /premium with the bot preselected.
  • Shop — votes on a bot have always converted into a spendable credit
    balance in Popplio, but there was never anything to spend them on. Now
    there is, backed by five new Popplio-side effects (see Popplio's
    changelog for the full list — bonus premium days, a priority-placement
    boost, a featured homepage slot, a cosmetic Supporter badge, and a
    vote-cooldown-halving blitz):

    • /shop — pick one of your bots, see its available credits, convert
      unredeemed votes into credits, and buy any item its balance covers.
    • A "Featured Bots" section on the home page and a "Supporter" badge on
      bot cards/pages now show the two benefits that have a visible effect
      beyond stats you'd have to go looking for; a Vote Blitz banner appears
      on a bot's page while one is active.
    • A "Shop" button next to "Upgrade" on every bot in the dashboard's
      Bots tab.
  • Alerts & push notifications — Popplio has had a complete notification
    pipeline (per-user alert inbox, VAPID web push subscribe/unsubscribe,
    a live cron firing real push notifications when a vote reminder comes
    due) that Omniplex never consumed until now. No backend changes needed,
    this is entirely new frontend surface:

    • A bell icon in the header (signed-in only) opens a dropdown showing
      recent alerts (GET /users/{id}/alerts/@featured), each dismissible
      individually or via "Mark all read", with an unread-count badge.
    • usePushNotifications (new hook) handles the actual browser
      subscribe flow: registers public/sw.js (a minimal, dependency-free
      service worker — push/notificationclick handlers only, deliberately
      outside the Next.js build pipeline), requests notification
      permission, fetches the VAPID public key
      (GET /users/notifications/info), and posts the resulting
      PushSubscription to Popplio. Surfaced as an Enable/Disable toggle
      inside the bell dropdown, with a plain "not supported" state for
      browsers without Push API support.
    • ReminderToggle (new component, generic over bot/server) on bot and
      server detail pages next to the report button — "remind me to vote
      for this again," backed by Popplio's existing reminders API, checks
      current state on mount so it renders correctly whether or not a
      reminder already exists.
    • Dropdown gained an optional panelClassName prop (defaults to the
      existing w-44) so the alert panel isn't squeezed into a menu-width
      box.
  • /shop now shows purchase history — the endpoint and API resource
    existed from the original build but were never actually rendered
    anywhere.

  • Support Tickets — Popplio gained a full standalone ticket system (see
    its own changelog), surfaced here as /tickets (your open/closed
    tickets), /tickets/new (topic picker + subject + message form), and
    /tickets/[id] (thread view — reply while open, close any time, reopen
    if you're staff).

  • A round of visual consistency work:

    • BotCard/ServerCard now get a subtle accent-tinted gradient
      background and border when premium, certified, or (bots only)
      Supporter-badged, instead of blending into the same plain gray border
      as every other card.
    • The home page's Featured and Spotlight sections got a matching
      accent-tinted band background, since they're literally the paid/
      hand-picked real estate on the page.
    • OmniplexLogo's brand-accent shape now fills with var(--accent)
      instead of a hardcoded #4943cb, so the logo itself follows whatever
      accent color a user has picked in Customize.
    • HomeTabs and /search's tag filter chips now use the same
      bg-accent/10 text-accent / bg-accent text-accent-fg active-state
      styling the rest of the app (/bots's Newest/Trending toggle, the
      dashboard's tab bar) already used — both were quietly still on plain
      black/white or gray.
    • HomeTabs' tab strip and its Bots/Servers toggle no longer wrap onto
      a second row on narrow screens; the tab strip scrolls horizontally
      inside its own flexible region instead, with the toggle pinned to the
      right on the same line.
    • The hero's rotating word (RotatingWord) no longer stacks onto its
      own line for longer words like "servers"/"packs" — it stays inline
      with "Discover the best Discord" the way the static "bots" text
      always did.
  • /search's query box now nudges toward /premium, /shop, /apps,
    or /tickets when the search text looks like it's after one of those —
    Popplio's search index only ever covers bots/servers (confirmed in
    types/search.go), so a query for "premium" or "ticket" would
    otherwise just come back with an empty/irrelevant result set instead of
    pointing anywhere useful.

Fixed

  • PackCard's avatar stack (bot and server packs alike) read
    bot.user.avatar/server.avatar directly instead of going through
    mirroredAvatarUrl like every other avatar in the app — some of those
    raw URLs 403 through the CDN mirror's stale-URL handling, which is why
    a pack with 4+ real bots could show fewer real avatars than expected in
    its card preview while the pack's own detail page rendered them all
    fine (it already went through BotCard/ServerCard, which never had
    this bug).
API

v1.2.0

v1.2.01d ago

Added

  • Packs are no longer bots-only: a new pack_type column (bot | server
    | emoji, immutable after creation) generalizes the existing BotPack
    type, and a new pack_emojis table 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 the Servers []string field that already existed on BotPack but
    was never wired to any route or UI. add_pack/patch_pack validate
    content per type (bot packs need bots, server packs need servers,
    emoji packs need emojis, capped at 50), get_all_packs gained an
    optional ?pack_type= filter, and a new edit_packs entity 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, new routes/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_reports staff 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/@all and GET /servers/@all gained an optional
    ?sort=trending param, 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_votes had 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 by reason/status only (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/stickers for servers with
    show_emojis = true. IndexServer (what @all returns) excludes
    emoji/sticker data entirely, so a cross-server emoji/sticker browse page
    had no way to bulk-fetch this without N+1 calls to GET /servers/{id}
    before this.
Website

v0.1.2

v0.1.21d ago

Added

  • Packs now come in three flavors — Bot, Server, and Emoji — instead of
    bots-only. /packs/add gains a pack-type selector as its first step;
    bot/server packs reuse the existing search-and-pick flow restricted to
    one type at a time, while emoji packs get a new upload sub-flow
    (EmojiPackBuilder.tsx, up to 50 emojis, 256KB each, static or animated
    GIF) built on the existing /api/uploads gateway (new pack-emoji kind,
    gated by a new edit_packs permission check against Popplio). Emoji
    images live at a deterministic CDN path (packEmojiUrl(), same
    convention as bannerUrl()) rather than a database-stored URL. Pack
    cards, the pack detail page, and the /packs listing (now filterable by
    ?pack_type=) all show a PackTypeBadge and type-appropriate preview.
  • A "Report this pack" flow (components/reports/ReportModal.tsx, built
    generic over target type so bots/servers can reuse it later) for
    flagging license/ToS violations, spam, or anything else — motivated
    specifically by user-created emoji packs raising real rights questions.
    Reports are reviewed by staff only, at a new /admin/reports page
    (status-filterable queue + detail modal with Resolve/Dismiss and an
    optional note) gated on a new review_reports permission — reporter
    identity is visible there and nowhere else; a pack owner never learns
    who reported them, only that a report exists and its reason category.
  • ReportModal rolled out to bot and server detail pages, not just packs —
    it was already built generic over target type for exactly this.
  • An RSS feed for /changelog (/changelog/rss.xml), hand-built (no
    existing precedent in this repo for a manual XML route — sitemap/robots
    use Next's typed MetadataRoute convention, which has no RSS
    equivalent) from the same getChangelogEntries() the page itself uses,
    same 15-minute cache window. Linked from the changelog page header and
    its metadata (<link rel="alternate" type="application/rss+xml">).
  • A "Trending" sort on /bots and /servers, alongside the existing
    newest-first default — ranks by net votes in the last 7 days (via
    Popplio's new ?sort=trending param) instead of raw vote count, so a
    bot/server picking up votes right now surfaces even if its all-time
    total is small.
  • /about/moderation: a public, anonymized breakdown of content reports
    by reason and status (counts only — no report/target/reporter
    identity), backed by Popplio's new GET /reports/stats. Linked from
    /about and the footer, next to the existing status page link.
  • /emojis: a browse page aggregating custom emojis/stickers across every
    server that's opted in to showing them (show_emojis), grouped by
    source server and reusing the existing EmojiStickerGallery card
    treatment. Backed by Popplio's new GET /servers/@emojis, since the
    regular paginated server listing excludes emoji/sticker data entirely.

Fixed

  • Uploaded images (banners, avatars, pack emojis — anything served through
    /cdn/[...path]) could stay visibly stale for up to a day after a
    re-upload: the fixed per-entity URL never changes, but the response was
    cached max-age=3600, stale-while-revalidate=86400, so a browser (or
    anything in front of it) could keep serving the old bytes long after a
    new upload landed. The route now sets max-age=0, must-revalidate and
    compares the request's If-None-Match against RustFS's own ETag (now
    captured in getObject) — an unchanged image gets a bodyless 304 (cheap,
    effectively instant), a changed one gets the new bytes on the very next
    request instead of waiting out the old cache window.

Security

  • /cdn/avatar-mirror/[targetType]/[id]'s ?src= param was passed
    straight to fetch() with no validation (CodeQL js/request-forgery)
    — since this route is public and unauthenticated, anyone could point
    ?src= at an arbitrary internal URL and make the server fetch it, and
    the response would then get putObject'd into the shared bucket at a
    predictable path, serving whatever the attacker's URL returned to every
    future visitor of that avatar. ?src= is now checked against an
    allow-list of exactly cdn.discordapp.com (the only host dovewing ever
    actually resolves an avatar to) before the fetch happens at all, not
    after.
Website

v0.1.1

v0.1.12d ago

Added

  • New public /changelog page, replacing the old database-backed changelog
    system (popplio's changelogs table, arcadia/panel/ops_content.go's
    updateChangelog RPC, and the ChangelogAction DTOs) — that system was
    already fully dead: the RPC unconditionally returned 403 "not
    implemented", the panel's "Changelogs" stat was hardcoded to 0, and there
    was no route or admin UI on either side. /changelog instead pulls
    releases directly from GitHub (lib/github/releases.ts) for a
    configurable list of repos (lib/github/config.ts — currently Popplio
    and Omniplex itself), merges them into one reverse-chronological
    timeline with a per-repo filter, and renders each release's body through
    the existing sanitized Markdown component. No new database or admin
    surface — GitHub Releases is the source of truth now, cutting a release
    there is the entire authoring flow. Cached for 15 minutes
    (next: { revalidate: 900 }) to stay off GitHub's unauthenticated rate
    limit without needing a redeploy to pick up a new release; set
    GITHUB_TOKEN (server-only) to raise that limit if it's ever hit.

  • Real image hosting, replacing the informal legacy-CDN-path guessing used
    everywhere so far (bannerUrl/partnerAvatarUrl/teamAvatarUrl
    previously just hoped a file existed at a fixed path, with no way to add
    new ones). The old on-disk cdn.omniplex.gg static files were migrated
    into a private RustFS (S3-compatible) bucket; since it's private, none of
    it is reachable directly, so everything now goes through two new
    same-origin proxy routes that hold the only S3 credentials (server-side
    only, never shipped to the client):

    • /cdn/[...path] — serves uploaded assets (partner logos, team
      avatar/banner, bot/server banner) straight from the bucket.
    • /cdn/avatars/{bots,servers}/[id] — bot/server avatars are Discord's
      own, synced live via dovewing/Infernoplex, not an upload; this mirrors
      a copy into the bucket on first request and re-serves it for 24h
      before quietly re-mirroring, cutting down on repeated live hits to
      Discord's CDN across every card/avatar in the app.
    • A new /api/uploads route backs real upload UI in the admin partner
      editor, team settings (avatar + banner), and the bot/server edit
      modals (banner). Every upload re-verifies identity and the specific
      permission needed server-side before writing a single byte — a staff
      loginToken checked via arcadia.hello for partner logos, or a user
      session token checked via POST /auth/test plus Popplio's entity-perms
      lookup for everything else — since the client-side hasPermString
      checks that gate the upload buttons were only ever a UI hint, not a
      security boundary.
  • The Customize panel is now tabbed (Colors / Fonts / Layout / Content)
    instead of one long stacked list, and grew three new levers:

    • Colors — 5 more accent options (cyan, teal, lime, red, pink), 12 total.
    • Fonts — added Inter and Space Grotesk alongside the existing four.
    • Layout — Compact/Comfortable/Wide page width, applied through a
      --container-max CSS var so it works on server-rendered pages too
      (Container reads the var instead of a fixed max-w-7xl).
    • Content — a "Hide NSFW content" toggle that removes nsfw-flagged
      bots/servers from every browse/search/profile listing site-wide, via a
      data-nsfw attribute on BotCard/ServerCard/PackCard and a plain
      CSS rule ([data-hide-nsfw="true"] [data-nsfw="true"] { display: none })
      — no per-page filtering logic needed. Detail pages are unaffected by
      design (this hides cards from browsing, not a page someone linked
      directly). A "disable cookies" option was requested alongside it but
      skipped: Omniplex doesn't set any analytics/tracking cookies to begin
      with, only the session cookie sign-in itself requires, so there'd be
      nothing for the toggle to actually do. Alongside it, a "Blur NSFW
      thumbnails" toggle (on by default) blurs just the avatar/banner images
      on nsfw-flagged cards via [data-blur-nsfw="true"] [data-nsfw="true"] img { filter: blur(...) }, leaving the title/description legible. It's
      disabled in the UI (and its applied state forced off) whenever "Hide
      NSFW content" is on, since there's nothing left to blur once nsfw cards
      never render at all.
  • Banner images for bots, servers, and teams (BotCard/ServerCard, plus
    the bot/server/team detail pages). Popplio's live API has no banner
    field at all any more — Popplio's own conformance notes confirm the whole
    CDN-upload pipeline it depended on was removed — but a historical
    one-time migration left every existing banner sitting at a fixed CDN path
    keyed by the entity's own ID (banners/{bots,servers,teams}/{id}.webp),
    discovered from that migration script since Popplio's API no longer
    advertises it anywhere. New bannerUrl() builds that URL; new Banner
    component renders it and falls back to a themed gradient (using the
    viewer's own accent color, so it tracks Customize) for anything that 404s
    or never had one uploaded — same idea as the existing partner-avatar CDN
    fallback.

  • Webhook management for bots and servers, from a new "Webhooks" dashboard
    dropdown item on each listing: create/edit/delete webhooks (HMAC, simple
    secret, or legacy auth), pick an event whitelist, send test deliveries
    with dynamically-rendered variable inputs per event type, and browse
    paginated delivery logs. Built entirely on existing Popplio webhook
    routes that had no Omniplex UI before now.

  • A "Change Team" dashboard action for bots, using Popplio's
    PATCH /users/{uid}/bots/{bid}/teams, letting an owner move a bot to any
    other team they have "Add Bots" on. Servers have no equivalent transfer
    endpoint in Popplio, so this is bots-only for now.

  • A public /partners page — the GET /list/partners client and types
    already existed (used on the homepage) but nothing ever linked to a
    dedicated page. Groups partners by partner type, shows their links and
    (if set) a direct link to their bot listing.

  • The main header nav gets the same dropdown-grouping treatment as the admin
    one: Bots/Servers/Packs collapse into "Browse", and Blog/Partners/
    Documentation/About — previously footer-only — collapse into "Community",
    reachable from anywhere now instead of just the footer.

  • Public and admin Search both pre-fill with real content on load instead of
    a blank page: admin search runs an empty query (which matches everything)
    on mount and on target-type switch; public search browses the full
    bot/server listing (real backend pagination via /bots|servers/@all)
    until a query or tag is submitted, at which point it switches to paginated
    search results (client-side, since /list/search has no server-side
    pagination).

  • Three new staff admin sections, backed by Arcadia panel RPC methods that
    already existed in Popplio but had no Omniplex UI at all:

    • Blog (/admin/blog) — full list/create/edit/delete for posts on the
      public /blog section, using the UpdateBlog RPC (manage_blog).
      New posts publish immediately; existing ones can be toggled to draft.
    • Partners (/admin/partners) — full CRUD for featured partners
      (UpdatePartners, manage_partners), including link validation
      (must be https://). Partner types aren't manageable from here yet —
      there's no RPC for creating one, only for assigning an existing type
      to a partner.
    • Disciplinary Types (/admin/staff/disciplinary-types) — full CRUD
      for staff warning/suspension templates (UpdateStaffDisciplinaryType,
      manage_disciplinaries): self-assignable, additory, needs-approval,
      max expiry, and a permission-limit picker reusing ArcadiaPermSelector.

    Two other backend-ready gaps were identified but deferred (larger scope):
    staff application review (GET/PATCH /staff/apps*) and the shop/economy
    admin surface (vote-credit tiers, shop items, item benefits, coupons,
    bot whitelist — five separate CRUD areas under ops_shop.go).

  • A "Test" button on a freshly created token, using Popplio's existing
    POST /auth/test to confirm it actually authorizes before dismissing it —
    disabled for now pending a Popplio deploy (see Popplio's changelog for the
    bug that endpoint needed fixed first).

  • API token management, backed by Popplio's existing (previously unexposed
    on the frontend) generic /{target_type}/{target_id}/sessions endpoints:
    a new "API Tokens" dashboard tab for a user's own personal tokens, and a
    "Tokens" button on bot/server cards (visible to team members with
    view_sessions/manage_sessions) for tokens scoped to that bot or
    server. Supports creating a token with a name, expiry, and an optional
    restricted permission set (via the same PermSelector used for team
    member permissions), and revoking existing ones. A newly created token's
    raw value is shown exactly once, since Popplio never stores or re-serves
    it after creation.

  • Votes now go through Popplio's new self-hosted proof-of-work captcha
    (see Popplio's changelog) automatically, when the bot/server hasn't opted
    out via captcha_opt_out. useVote fetches a challenge, solves it
    client-side with crypto.subtle (lib/captcha/pow.ts), and submits the
    solution alongside the vote; both vote buttons show "Verifying…" while
    that's in progress. No user-facing setup — it's invisible on a successful
    vote and only costs a brief moment of CPU work.

Changed

  • Homepage, bot/server index/listing/detail pages, and blog list/post pages
    now fetch with cache: "no-store" instead of Next's ISR (revalidate)
    — blog posts in particular had crept to a 1-hour window, meaning an edit
    or correction could take up to an hour to show up. list.getStats() and
    list.getPartners() (homepage stats and partner strip) had no cache
    option set at all, which under Next's fetch defaults meant force-cache
    — cached indefinitely until the next deploy, not just "stale for a
    while" like everything else. All of it now hits Popplio directly on
    every request instead. Search was already no-store; this just brings
    the rest in line with it rather than leaving the CDN/ISR layer doing
    double duty as an ad-hoc data cache on top of what it's actually for
    (serving images). Worth revisiting if Popplio's own load becomes a
    problem at higher traffic, but not a concern at current scale.
  • The header's "Create" menu (NavGroupMenu) was hardcoded hidden md:block,
    so it silently never rendered below the md breakpoint — mobile users
    could only reach Add Bot/Server/Pack and Create Team by opening the full
    hamburger drawer. Unhid it; it now works as an actual dropdown from the
    collapsed mobile header too. Browse/Community's NavGroupMenu instances
    stay desktop-only since their parent nav row already is, so nothing
    changes for those. Also gave both NavGroupMenu and the generic
    Dropdown component a max-w-[calc(100vw-1.5rem)] safety clamp so their
    panels can't overflow off-screen on narrow viewports.
  • Bot detail page dropped the "Prefix" stat — most bots are slash-command
    only now, so it was frequently blank or stale. Both bot and server detail
    pages gained "Page Views" and "Invite Clicks" stats instead, using data
    the API already returned but never displayed. The bot's OpenGraph
    share-image (a separate, independent stat list from the detail page) had
    the same stale "Prefix" stat — swapped for "Page Views" there too, and the
    server OG image gained a third stat ("Page Views") to match.
  • Tightened-up spacing pass: the dashboard and team settings tab bars had
    almost no top padding (pt-1) and nearly-touching tabs (gap-1), and the
    API Tokens list rows used noticeably less padding than every other list
    card in the app. Bumped both to match the spacing used elsewhere.
  • Admin panel polish pass: the nav bar had grown to 10 flat links as sections
    were added — related pages now group into "Staff" and "Content" dropdowns
    (new NavGroupMenu, also used to rebuild the existing "Create" menu for
    consistency). Every admin list page now shares one header component
    (AdminPageHeader) and container width (max-w-5xl — several pages were
    still on max-w-4xl, causing the page width to visibly jump between
    sections). RPC Logs and Partners showed raw Discord user IDs where every
    other admin page already resolves them to a username/avatar (matching the
    Bot Queue's existing claimed_by resolution) — both now do the same.
  • Bot/server dashboard cards were getting crowded as more per-item actions
    were added (Stats, Tokens, Delete). View and Edit stay as direct buttons;
    everything else now lives behind a "more actions" dropdown (new
    components/ui/Dropdown.tsx).
  • Accent-color customization is more visible throughout the site instead of
    being mostly confined to buttons and links: the homepage hero highlights
    "best" in the accent color, the header nav's info badges (Certified,
    Staff, Pending, etc. — previously a hardcoded blue regardless of chosen
    accent) now tint with the selected accent, and the Bot/Server/Pack/Team
    dashboard cards all share one accent-tinted hover treatment (border, title
    color, and arrow icon) instead of Pack and Team cards using a plain zinc
    hover that Bot/Server cards had already moved past. The homepage's
    Partners chips and News & Updates cards were missed in that first pass —
    now match. Dark-mode card borders were also bumped from zinc-800 to
    zinc-700: against the zinc-950 page background and zinc-900 card
    fill, the old border was too close in lightness to read as a border at
    rest, so cards looked edgeless until hovered.

Fixed

  • Partner and team avatars (/cdn/avatars/{partners,teams}/...) 404'd on
    every request, even for files confirmed to exist in the bucket. Next.js's
    router matched those URLs against the bot/server avatar mirror route
    (/cdn/avatars/[targetType]/[id], 2 segments after avatars — same shape
    as avatars/partners/<file>.webp) instead of the intended static-asset
    catch-all, and that route 404'd immediately since "partners"/"teams"
    aren't "bots"/"servers", never touching S3 at all. Moved the mirror
    route to /cdn/avatar-mirror/... so the two can't collide on URL shape.
  • Team.avatar was silently blank everywhere it was used (dashboard Teams
    tab, /teams/[id], TeamPicker, and the team-owner blocks on bot/server
    detail pages) — the exact same class of bug as the partner-avatar one
    below. Popplio's API dropped Team.avatar entirely along with the rest of
    its CDN pipeline, so resolveAsset(team.avatar) always resolved to null;
    the frontend type still claimed AssetMetadata | null as if the field
    were live. Found while wiring up banner support, which uses the same
    legacy-CDN-path pattern. Replaced every call site with teamAvatarUrl()
    and removed the stale avatar/banner fields (and now-unused
    AssetMetadata/resolveAsset) from the API types entirely rather than
    leaving them typed as something Popplio no longer sends.
  • The Customize panel (accent/font picker) was positioned with a hardcoded
    fixed inset-0 guess (pt-16 pr-4) instead of anchoring to the gear icon
    that opens it, so it visibly floated in the wrong spot whenever the header
    layout shifted. Switched to the same anchored-dropdown pattern already
    used elsewhere (components/ui/Dropdown.tsx) — it now opens directly
    under its trigger button.
  • The homepage's Partners section resolved each partner's avatar via
    resolveAsset(partner.avatar), but Popplio's public partner response has
    no avatar field at all (only ever had it on the type, not the wire) — a
    partner's image is partner.user.avatar, already a full resolved URL.
    Every partner avatar on the homepage was silently blank as a result. Fixed
    there and used correctly in the new /partners page.
  • That fix was itself using the wrong source: partner.user.avatar is the
    linked Discord account's avatar, not the partner's actual logo. Popplio's
    CDN-upload pipeline for partners was removed outright (see its
    CONFORMANCE.md §D11b), but the old manually-uploaded logos are still
    sitting on the CDN at a fixed, undocumented path keyed by partner ID
    (avatars/partners/<id>.webp). All three partner-avatar call sites
    (homepage, /partners, and the admin edit modal's new preview) now
    resolve there via partnerAvatarUrl(), falling back to a generated
    avatar for any partner without a file at that path.
  • Staff Panel sign-in on prod failed with a misleading "Method Not Allowed"
    whenever NEXT_PUBLIC_ARCADIA_URL was configured with a trailing slash:
    postQuery() always appends its own /, so the request landed on a
    double-slash URL, which the panel API 301-redirects to the real path —
    and per the Fetch spec, a POST following a 301 is replayed as a GET,
    which the panel API correctly (but confusingly) rejects with
    405 Method Not Allowed. ARCADIA_URL now strips any trailing slash.
  • On mobile, the vote button lived in the sidebar, which renders after the
    entire About section and reviews in single-column layout — voting meant
    scrolling past all of it first. Bot/server detail pages now also render a
    compact copy of the Actions card (Add to Server/Join + vote button) right
    after the tags, lg:hidden, with the sidebar copy switching to
    hidden lg:block so nothing renders twice on desktop.
  • Several sign-in prompts away from the dashboard/add flows — bot and
    server vote buttons, the review prompt, and both header sign-in links —
    used a plain <Link href="/auth/login"> that didn't record where the
    user came from, so signing in from e.g. a bot's page bounced back to the
    homepage instead. New SignInLink component wraps next/link and sets
    the same auth_redirect localStorage key useRequireAuth already used
    correctly elsewhere, swapped into all five sites.
  • Downvoting fired immediately on click, with no confirmation and no way to
    undo it (feedback: a downvote can't be removed or changed once cast).
    Both vote buttons now confirm via a modal first, stating the entity's
    actual cooldown window (4h premium / 12h standard, 6h on double-vote
    weekends — voteCooldownHours()) so it's clear voting again isn't
    possible until then either. Popplio has no vote-removal endpoint today,
    so "undo" itself is a backend gap being tracked separately, not something
    this fixes.
  • The widget preview card (WidgetShare) clipped its image with
    rounded-lg (8px) while the widget itself draws its own corners at 16px
    (WidgetFrame) — the mismatch let the image's actual corner curve peek
    past the tighter clip, showing a mismatched color ring at each corner.
    Clip radius now matches. Separately, the accent-color swatch row sat
    alongside the theme toggle in one justify-between row and overflowed
    the card at typical sidebar widths (12 swatches don't fit next to the
    toggle); it now sits on its own wrapping row underneath.
  • Server emoji/sticker galleries were bare icon grids with no name shown
    and no hover feedback, inconsistent with every other card style in the
    app. Both now use the shared card hover treatment (accent border/bg, a
    slight hover scale) and show the emoji/sticker's name as a caption
    instead of only a title tooltip, plus a visible count next to each
    section heading.
API

v1.1.0

v1.1.02d ago

Added

  • Infernoplex the standalone Rust Discord server-tracking bot has been
    ported into Popplio's own binary as a new infernoplex/ package, the same
    treatment Arcadia got earlier. main.go now 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 (cmdStats version/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 (update config.yaml before deploying): a new
    infernoplex: block with client_id/client_secret plus per-environment
    prefix/server_port/token (same Differs[T] staging/prod/beta/dev
    pattern used elsewhere) — see config.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
    dead HCaptchaInfo scaffolding in types/vote.go (which was never wired
    to anything) with something actually enforced. GET /votes/captcha/challenge issues a signed, stateless hashcash-style
    challenge (find a nonce so sha256(salt+":"+nonce) has N leading zero
    bits); PUT .../votes now requires a solved challenge in the request body
    for bot/server votes unless the entity has opted out via the existing
    captcha_opt_out setting. Challenges are HMAC-signed with the new
    captcha.hmac_secret config 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 (update config.yaml before deploying): a new
    captcha: block with a per-environment hmac_secret (same Differs[T]
    pattern used elsewhere) — see config.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 /analytics embed title (and its frozen conformance string),
    and the fallback SEO description on GET .../teams/{id}/seo when 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.Say builds the
    embed itself, so this is a change of container rather than of wording —
    every string frozen in arcadia/conformance is 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,
    and Ctx.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 through Ctx,
    build the same shape by hand (modalReply in
    arcadia/bot/interactions.go). TestRepliesAreEmbeds walks the package's
    AST for any MessageCreate that sets Content and fails if one appears.
  • A second pass over the same files, this time pulling out the repetition
    rather than only moving it. In arcadia/rpc: modLogReason builds the
    mod-log embed nine handlers were each building by hand (title,
    description, one Reason field, footer, colour), reasonField covers the
    four multi-field embeds that keep their own shape, and
    guardBot/guardUser replace the ten copies of "reject an over-long
    reason, then check the target exists". certifyAdd went from 47 lines to
    20 this way, and review.go split into claim.go and verdict.go once
    it had. In arcadia/panel: authorize replaces the ten copies of the
    twelve-line checkAuth + resolvedPerms preamble, and ops_core.go
    (608) split into ops_auth, ops_hello, ops_queue, ops_rpc,
    ops_search and ops_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/conformance finds 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, since arcadia/dbconform PREPAREs every string literal it can
    find against a real database. And the five steps of StaffResync are
    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) into staffmgmt.go (the role model, the authority rules, the
    lookups), staffroles.go, staffperms.go and staffrender.go;
    arcadia/bot/commands.go (697) into commands.go (the registry and the
    two shared RPC helpers) plus help.go, invites.go, stats.go and
    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.go where the shop operations that also use them can find
    them; and arcadia/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, and routes/'s one-package-per-endpoint layout is
    left alone since uapi requires 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.rpcPermissions groups 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, plus dispatch.go
    for the method-to-handler switch and audit.go for the
    staff_general_logs write. core.go keeps the Execute pipeline 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,
    and arcadia/conformance scans the whole package rather than one file,
    so it pins the embed strings exactly as before. (review.go was later
    split further into claim.go/verdict.go — see the dedup-pass bullet
    above; arcadia/CONFORMANCE.md's file references still say review.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's OnGuildsReady handler). 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_cdn purged 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 other exp/
    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's Naevis struct (added alongside Infernoplex as an
    apparent placeholder for a second bot) is dead code it's never
    referenced from the top-level Config struct despite its fields being
    tagged validate:"required", and config.yaml.sample's naevis: section
    was already removed. Safe to delete outright, or finish wiring it up if
    Naevis is still planned.
  • arcadia/CONFORMANCE.md references a arcadia/rpc/review.go in a few
    places (issues #6, #9, #10) that doesn't exist the file is verdict.go.
    Looks like a stale rename from drafting the Isabelle split.
API

Staff Tooling Update & Bug Fixes

v1.0.111d ago

Changed

  • Every Differs[T] config key (DB tokens, site URLs, etc.) previously
    required both a staging and a prod value to be set regardless of
    which environment a given box actually runs — a current-env: prod box
    was rejected at startup for a missing staging value it would never
    read, and vice versa. ValidateDiffers now only requires whichever value
    Parse() will actually resolve for CurrentEnv (prod needs prod,
    staging needs staging; beta/dev still accept either their own
    value or a staging fallback, 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-time grant/revoke subcommands are unchanged and
    still work; both paths share the same rank check and perms.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". edit is
    registered as the first subcommand of both commands, since Discord lists
    them in registration order and never lets the parent command
    (/staffroles on its own) be invoked at all.

Fixed

  • DELETE /teams/{tid}/members/{mid} had its last-owner safety check
    inverted (introduced by the kittycat→internal perms package 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.
    SyncCommands now 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/team auth types were never registered as OpenAPI security
    schemes (only User/Bot were, via docs.AddSecuritySchema in
    main.go) even though Authorize() has always fully supported them —
    every one of the 41 operations requiring server or team auth
    (PUT /bots, PUT /servers, both PATCH .../settings endpoints,
    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's APIPage, which throws
    Cannot read properties of undefined (reading 'type')). Registered both
    (docs.AddSecuritySchema("server", ...) / ("team", ...), lowercase to
    match AuthTypeMap'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
    OnGuildsReady instead of right on startup — that fix only addressed the
    timing, not the actual cause: Popplio runs sharded (OpenShardManager),
    and Discord.SetPresence only ever checks disgo's single-gateway field
    (populated by OpenGateway, not OpenShardManager), so it returns
    ErrNoGateway unconditionally on a sharded bot regardless of readiness.
    OnGuildsReady also 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.Authorize reads PERMISSION_CHECK_KEY out of
    the route's ExtData unconditionally, but the synthetic uapi.Route{}
    this endpoint builds to call it never set ExtData at 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 in api_sessions) ever got a real
    response (401). Now sets a no-op PermissionCheck (NeededPermission
    always returns nil), 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_borealis staff permission. Borealis was removed from the platform
    during the port (arcadia/CONFORMANCE.md D11a — the arcadia.borealis_url
    config key, the client and the Approve call to it are all long gone), so
    the permission has gated nothing since and only added a line to
    /permissions and a row to every permission picker. exp/rewrite/flatperms.sql
    now lists the old borealis.* in retired_perm (dropped on purpose)
    instead of mapping it onto use_borealis, and
    exp/rewrite/remove_borealis_perm.sql strips it from
    staff_positions.perms, staff_members.perm_overrides and
    staff_disciplinary_types.perm_limits for 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, so use_borealis would 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 through arcadia.owners
    (perms.ErrBotAccount). Previously nothing stopped one: StaffResync
    walks every member of the staff server and creates a staff_members row
    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.StaffGrants carries a BotAccount flag, joined in from
      dovewing's user cache by LoadStaff at no extra cost, and Resolve()
      returns nothing and Rank() returns NoRank when 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.RejectBotAccount resolves through dovewing all the way
      to Discord if the account has never been seen, and fails closed if it
      cannot tell. StaffResync now 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's editMember and the staff bot's
      /staffperms grant/revoke/edit refuse a bot target outright.
API

v1.0.0

v1.0.011d ago

Added

  • current-env now also accepts beta, a fourth environment alongside
    staging/prod/dev. Every Differs[T] config key gains an optional
    beta value (config.Differs[T].Beta), consulted only when current-env
    is beta and falling back to staging when unset — same mechanism as
    dev's override, but without dev's relaxed Staging/Prod requirement:
    beta is validated exactly like staging/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 explicit beta: value.

  • bgtasks package: a new home for Popplio's own periodic background jobs,
    separate from arcadia/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
    into bots.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 (see arcadia/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's serversync task, which now also syncs every
    listed server's icon (not just opted-in ones' emojis/stickers) from its
    gateway cache. Requires the new servers.avatar column
    (exp/add_servers_avatar.sql, needs to be applied manually against the
    database like other exp/ scripts).

  • Webhooks gained a new hmac_auth mode (hmac_auth on
    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_auth defaults to off and the
    splashtail/simple_auth protocols are unchanged and fully supported
    this only adds a third option, it doesn't remove or alter the other two.
    Requires the new webhooks.hmac_auth column
    (exp/webhookhmacauth.sql, needs to be applied manually against the
    database like other exp/ scripts).

  • current-env now also accepts dev, a third environment alongside
    staging/prod. Every Differs[T] config key (config/config.go) gains
    an optional dev value, only consulted when current-env is dev, and
    only used if actually set — an unset dev value falls back to staging,
    so no existing config.yaml needs 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 treats dev the
    same as staging rather than falling through to production behavior.

  • PUT /servers add 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 has bot.add-equivalent
    permission on).

  • Packs can now include servers alongside bots: a servers column,
    resolution into full IndexServer objects, and matching validation on
    both POST /packs (create) and PATCH /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 resolved user.status returned
    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 as online rather 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 with PUT /servers via a new ResolveInvite helper.

  • Servers can opt in to showing their custom emojis and stickers on their
    listing page via a new show_emojis setting (PATCH /servers/{id}/settings).
    GET /servers/{id} now includes emojis/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/meta now also reports bot_present/bot_invite_url by
    asking Infernoplex's Sorbet API whether the tracking bot is currently a
    member of the resolved guild, via a new CheckBotGuildPresence helper.
    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.EntityVoteInfo no longer hardcodes SupportsDownvotes = false for
    the bot target type).
  • meta.popplio_proxy now defaults to https://gateway.nodebyte.host/proxy/discord
    (the shared parent-company gateway), replacing the old local
    http://127.0.0.1:3219 twilight-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 an X-Upstream-Authorization
    header instead, which the gateway forwards as the real Authorization
    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
    with FILTER, instead of two separate COUNT(*) round trips.
  • Bot/server index resolution (ResolveIndexBot/ResolveIndexServer,
    called by GET /bots/@all, GET /servers/@all, search, random, the bots
    index, packs, team entities, and user profiles) now resolves every row in
    a page concurrently via errgroup instead 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-status now issues both the Instatus and UptimeRobot
    requests with the request's own context and a bounded client timeout,
    instead of an unbounded http.Get/http.NewRequest that could hang the
    handler indefinitely if the upstream stalled.
  • Deduplicated the page query-parameter parsing copy-pasted across nine
    endpoints (each with a slightly different error response for the same
    invalid-page case) into a shared pagination.Parse helper.
  • DELETE /users/{uid}/packs/{id} and PATCH /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 /servers and PUT /bots still wrote the legacy wildcard string
    global.* into a new team's team_members.flags when creating the
    owner's membership, instead of the flat model's owner permission
    (perms.EntityOwner). exp/rewrite/flatperms.sql converts 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, since global.* isn't owner and isn't a declared
    permission either). arcadia/tasks/cleaners.go's TeamCleaner task had
    the same bug in both directions: it looked for orphaned-of-owner teams by
    querying flags @> ARRAY['global.*'] (which will now never match
    anything, since the migration already converted every existing row) and
    wrote global.* back when promoting a replacement owner. All three now
    use perms.EntityOwner.
  • Every mod-log notification embed (PUT /servers, PUT /bots,
    DELETE /bots/{id}, and the two PATCH .../settings endpoints below)
    built its link back to the site with Sites.Frontend.Production(),
    forcing the production URL regardless of which environment the action
    actually happened in. On staging/beta, this meant either a broken link
    (if sites.frontend.prod wasn'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
    with 50035) 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}/settings and PATCH /bots/{id}/settings returned 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 with 50035: 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 real servers.avatar value instead of nothing, and a
    failure to post the notification is logged rather than failing the
    request, matching the existing pattern in PUT /servers.
  • PUT /servers wrote every field into the wrong column: createServerArgs's
    hand-written value order didn't match types.CreateServer's field
    declaration order, which is what db.GetCols/the generated column list
    actually follow. Values were bound to columns purely by position, so e.g.
    server_id was written into invite, name into short, and
    extra_links (a []Link) into tags (a text[]) — the last of which is
    what surfaced as a cannot find encode plan error, since a []Link can't
    encode into a text[] column. createServerArgs now lists values in the
    same order as the struct, with a comment on both explaining they must stay
    in sync (the existing length check on createServerColsArr/serverArgs
    only ever caught the two lists having different lengths, not entries being
    out of order relative to each other).
  • servers.extra_links was still text[] in the deployed database while
    the application code (and data/seed-ci.json's own schema conformance
    check) has expected jsonb for a while, matching bots/teams/users'
    extra_links columns. Migrated via exp/fix_servers_extra_links_type.sql
    (needs to be applied manually against the database like other exp/
    scripts, same as exp/rewrite/*.sql). Independent of the column-ordering
    bug above, but was masking it: the ordering bug meant extra_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.SetPresence was called right after
    Discord.OpenShardManager returned, but that only means the shards
    started connecting, not that the gateway session is actually usable yet
    (that's only confirmed later, asynchronously, via the OnGuildsReady
    event). SetPresence now runs from inside the OnGuildsReady handler
    instead, once shards are confirmed ready.
  • current-env: dev still required a real staging and prod value for
    every Differs[T] config key, and for every Arcadia staff-server
    channel/role/server ID, defeating the point of dev: 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 needs dev or a staging fallback to resolve; staging/prod
    keep the original both-required behavior), and Arcadia's staff-server
    fields use a new requirednotdev validator so they're only required
    outside of dev.
  • GET /servers/meta's route registration was missing the ExtData entry
    BaseSanityCheck requires whenever a route declares Auth, 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 ignored simple_auth in the request
    body — the UPDATE statement 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 raw map[string]any to Set, 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 before Set and 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_review each called
    state.Redis.Del(ctx, "rv-"+targetId+"-"+targetType) on every mutation,
    invalidating a cache key that is never Set or Get anywhere in the
    codebase — three no-op Redis round trips per review change. Removed.
  • The OAuth authorization-code replay check (create_oauth2_login) used a
    separate Exists followed by Set to mark a code used, which is not
    atomic: two concurrent requests carrying the same code could both pass
    Exists before either called Set, letting the same code be redeemed
    twice — exactly the race the code's own comment says it closes, but
    didn't. Now uses SetNX, which checks and marks the code used in one
    atomic round trip.
  • Several route handlers (delete_pack, patch_pack, current_status)
    returned a bare uapi.DefaultResponse(http.StatusInternalServerError) on
    DB/upstream failures without logging anything, so some production 500s
    were invisible in the logs. They now go through resp.Err, which is what
    the shared api/resp package 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/sender used panic() 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 no recover(), 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 an err variable shared
    with their enclosing handler, a data race; both now use a goroutine-local
    variable.
  • data/seed-ci.json, the schema snapshot the db_fields_check.py CI test
    checks Go struct db tags 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 excluded bots.cache_server_uninvitable, a
    real DB column with no corresponding Go struct field anywhere in the
    codebase, via the existing ignore_fields convention.
  • 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, only bot and server — 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 of null across list/user/team
    endpoints, preventing frontend crashes on .length/.map against a
    response that has no data yet.
  • GET /teams/{id} had the same nil-slice gap on tags/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/sender failed to build at all: an in-progress refactor had
    extracted Secret, webhookSendState, and several helper methods
    (resolveTarget, buildRequest, notify, markFailed, logFields)
    into new request.go/sendstate.go files, but sender.go still carried
    the old duplicate declarations and its own inline copies of the same
    logic. sender.go now 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.
Website

v0.1.0

v0.1.011d ago

Added

  • Downvotes: bot and server vote buttons now both show an upvote and a downvote control (bots previously only supported upvoting, both on the API and in the UI — see the matching Popplio change).
  • Statistics: bot and server owners/team members can now view a "Stats" panel from the dashboard, showing votes, page views, invite clicks and (for bots) uptime check results — data the API already returned but the frontend never surfaced.
  • A "Create" menu in the header (Add a Bot/Server/Pack, Create a Team), so those entry points are reachable from anywhere on the site, not just the footer and dashboard.
  • Team management: Add Bot and Add Server now let you choose which of your existing teams (that you have the relevant add_bots/add_servers permission on) should own the new listing, instead of always creating a brand-new team. A new /teams/add page lets you create a team directly, linked from the dashboard's Teams tab and the footer. Since team names aren't unique, the picker shows each team's avatar and member/bot/server counts to tell same-named teams apart, and gains a search box once you have more than a handful of eligible teams.
  • Knowledge base (/kb) section, with guide articles ported from the legacy
    site: Getting Started, Server Listing Rules, Pack Rules, Voting Rules & FAQ,
    and Partner Requirements.
  • Full staff admin panel (/admin), backed by the Arcadia panel API:
    • Discord OAuth2 login with mandatory TOTP MFA.
    • Bot Queue with quick actions (Claim/Unclaim/Approve/Deny), a generic
      dynamic-form modal covering every other RPC action, submission-order
      position numbers, and safe (no-permissions) vs. submitted invite links
      shown side by side.
    • Global entity Search (bots/servers) with the same action modal.
    • Staff Positions and Staff Members management, with hierarchy-aware
      edit/delete locking.
    • A live + hand-maintained permission catalog and selector for granting
      staff permissions.
    • Pagination on the queue, search results, staff members list, and RPC
      audit log.
    • Overview page (/admin) summarizing bot/server/ticket counts, total
      users, and changelog count via Arcadia's BaseAnalytics. The Bot Queue
      moved to /admin/queue.
    • Bot Queue now resolves claimed_by Discord IDs to usernames via
      Arcadia's GetUser, instead of showing the raw ID.
  • Reviews: star ratings and threaded replies on bots and servers, full
    create/edit/delete for the review's author, and a read-only view for staff
    in the admin panel.
  • Packs can now include servers alongside bots, and any existing bot or
    server can be added to a pack regardless of who owns it — packs are
    curated lists, not ownership-restricted.
  • Bot presence indicator (online/idle/dnd/offline) on bot cards and the bot
    detail page, sourced from a bot's self-reported status.
  • /about/status now also monitors Packs, Blog, Search, and the Staff Panel
    API, on top of the existing checks.
  • A reusable multi-link (extra_links) editor, wired into the bot add/edit
    forms and the new server add/edit forms — previously only a single
    synthetic invite link could ever be sent.
  • A working "Add a Server" flow: paste a Discord invite link and the server
    is resolved and listed automatically. The previous version of this page
    called a backend endpoint that didn't exist.
  • A dashboard "Servers" tab and ServerEditModal for managing servers owned
    by a team you belong to.
  • Widget embed system (Markdown/HTML/URL snippets, customizable stats and
    colors) for bots, servers, and packs.
  • The bot and server add forms now link to the relevant Knowledge Base rules
    (Bot Rules, Page Rules, Server Listing Rules) before submission.
  • The staff Member edit modal now shows a member's positions and their full
    resolved permission set (positions + overrides combined), not just the
    override layer being edited.
  • Add Bot and Add Server are now step-based flows, ported from the legacy
    site's pattern and extended with a confirmation step: enter an ID/invite
    and look it up, and a preview card shows the bot's real Discord
    name/avatar/server count/flags (or the server's name/icon/member counts)
    with a "Looks right — continue" / "Change" choice before the rest of the
    form unlocks — instead of blindly trusting whatever the submitter typed.
    Add Bot also carries over the legacy site's already-listed/not-public
    checks and the fallback_bot_id recovery path for when the anti-abuse
    provider is down.
  • Add Bot and Add Server drafts are now persisted to localStorage, scoped
    to the signed-in user (usePersistedFormDraft), so an in-progress
    submission survives not just a refresh but closing the browser entirely.
    Unlike the legacy site's single global draft key, drafts are namespaced
    per user so they can't leak between accounts on a shared browser, and
    they're cleared automatically on a successful submit.
  • Server owners can opt in ("Show this server's emojis & stickers") to
    displaying their server's custom emojis and stickers on its listing page,
    via a new toggle in ServerEditModal. The gallery only renders when the
    owner has opted in and there's actually synced data to show — nothing
    appears while the tracking bot isn't a member of the server.
  • Add Server now warns (without blocking submission) when the tracking bot
    isn't currently a member of the server being added, with a direct invite
    link — several features (emoji/sticker sync, real invite generation, live
    member counts) silently never work otherwise, and previously nothing told
    the owner why.

Changed

  • TagPicker now always shows a listing's actual tags as removable chips,
    even when they fall outside the curated suggestion list, and supports
    adding free-form custom tags — tags were never restricted to an enum on
    the backend, so the old fixed-list picker could silently make existing
    tags disappear from the editor.
  • PermSelector and ArcadiaPermSelector now surface any granted
    permission that isn't in their known catalog instead of hiding it
    entirely, so a permission the UI doesn't recognize is still visible and
    removable.
  • ArcadiaPermSelector checkboxes now reflect a member's fully resolved
    permissions (position grants combined with overrides), not just the raw
    override array — a permission granted through a staff position now shows
    as checked (labeled "via position"), and unchecking it correctly emits an
    explicit per-member revoke (~permission) instead of doing nothing.
  • Admin panel navigation is now part of the site's main header instead of
    running as a separate nav bar — one drawer-style menu across the whole
    site, admin section included, with an automatic "Staff panel" shortcut
    for staff and an "Exit staff panel" action while inside it.
  • Homepage and About page bot totals now include certified bots, not just
    approved ones, so the two pages no longer disagree with each other.

Fixed

  • The entire frontend permission system was still built for Popplio's old
    namespace.action model (dotted strings, * wildcards, ~ negators,
    global.* for "grants everything") after the backend moved to flat,
    self-describing permission names (edit_servers, manage_webhooks,
    owner) with no namespaces, wildcards or negation. Every permission
    check across the team and staff/admin panel UI was comparing against
    names that no longer exist, so team owners (and staff) with correct
    permissions server-side could still be denied client-side, or see broken
    panels:
    • lib/permissions.ts rewritten for flat exact-match, with owner/
      administrator as the two domain "super" permissions that imply
      everything (was reimplementing full namespace/wildcard/negator parsing).
    • Every literal permission string across dashboard, teams/[id],
      teams/[id]/settings, and the /admin staff pages (positions, queue,
      members) updated to its flat equivalent (e.g. team_member.add
      add_team_members, rpc.Claimreview_bots, global.*owner).
    • PermissionData (the shape of GET /teams/meta/permissions and
      GET /staff/meta/permissions) still had the old kittycat fields
      (supported_entities, data_override); the real response is
      {id, name, desc, category, dangerous}. This was the direct cause of
      TypeError: s.supported_entities is not iterable crashing the team
      settings page — something iterated a field that no longer exists.
    • PermSelector and ArcadiaPermSelector rewritten to group by the new
      category field instead of a namespace derived from
      supported_entities, and to toggle flat permission ids directly
      instead of building namespace.perm keys.
    • ArcadiaPermSelector additionally dropped negation support
      (~permission) entirely — the new model's overrides are purely
      additive (nothing subtracts from the union), so revoking a
      position-granted permission from a specific member is no longer
      possible from this editor; it now shows those as read-only ("via
      position") instead of offering a toggle that would silently do nothing.
    • Removed lib/arcadia/permissionCatalog.ts, a hand-maintained list of
      every permission string that existed because standalone Arcadia had no
      live catalog endpoint. Now that Arcadia is merged into Popplio, both
      GET /teams/meta/permissions and GET /staff/meta/permissions are
      real live endpoints (new staff API resource added), so the staff
      panel's permission pickers can't drift out of sync with the server the
      way the static list could.
  • Server avatars showed the initials fallback everywhere (server cards,
    detail pages, widgets, OG images, pack pickers) — IndexServer.avatar was
    still typed and handled as AssetMetadata (the old CDN-asset shape from
    before that system was removed), while the backend now returns a plain
    URL string. Every consumer called resolveAsset() on it, which always
    returned null for a string, silently falling through to the fallback.
    IndexServer.avatar is now typed as string and used directly, matching
    bots.avatar. Team/partner avatars are unaffected — those are still
    genuinely removed, unlike servers'.
  • ARCADIA_PANEL_SCOPE's fallback value was infinity-panel, which doesn't
    match any OAuth2 scope Arcadia actually registers (infinity-list) —
    staff panel login failed OAuth validation for anyone relying on the
    default rather than an explicit NEXT_PUBLIC_ARCADIA_PANEL_SCOPE env var.
  • The status page's "Staff Panel" check called arcadia.auth.begin(...)
    with a hardcoded production redirect URL (https://omniplex.gg)
    regardless of which environment was actually running. On staging, this
    sent the wrong origin to staging Arcadia's redirect validation, which
    legitimately rejected it — making a perfectly healthy panel API report as
    "May be affected". Now uses BASE_URL (already environment-aware) instead
    of a hardcoded value.
  • Markdown/HTML sanitizer:
    • <style>, <svg>, <object>, and <embed> are stripped along with
      their content, instead of leaving a <style> block's raw CSS visible as
      page text.
    • margin-top/right/bottom/left and padding-top/right/bottom/left
      (and the auto keyword inside shorthand margin/padding) were being
      silently dropped by the inline-style allowlist, breaking spacing on any
      custom-styled bio that used them.
    • Removed the forced divider auto-added under every <h1>/<h2>, which
      fought custom-designed descriptions that already had their own layout.
    • <iframe> embeds are now allowed (with a sane default height when the
      embed doesn't specify one), instead of being stripped outright.
    • background only ever validated a single flat color or a single
      gradient function — any bio layering a gradient over a solid fallback
      color, or stacking multiple gradients (a very common pattern for card
      backgrounds), had its entire background declaration silently dropped.
      Now validates each comma-separated layer independently.
    • box-shadow, text-shadow, backdrop-filter, display:grid /
      grid-template-columns, and the flex shorthand were entirely absent
      from the allowlist, silently dropping every declaration that used them.
      Elaborately-styled bios relying on any of these (card depth, blurred
      glass panels, responsive grid layouts) rendered as flat, stacked,
      unstyled blocks instead of the intended design.
  • Dashboard tab bar could scroll vertically as well as horizontally on
    narrow viewports, due to a CSS overflow-x/overflow-y interaction.
  • Bot/server/pack listing cards could force the whole page to overflow
    horizontally when tag or description content had no wrap point, due to
    CSS Grid items defaulting to min-width: auto.
  • The dashboard's Bots tab had no "Add a Bot" entry point, unlike the
    Servers/Teams/Packs tabs, which all had one in both their empty and
    populated states.
  • Team bots on the dashboard always showed "view only," even for users with
    full permissions on that team — traced to a Popplio bug (see Popplio's
    changelog) where team member data was never fetched for embedded teams.
  • TagPicker's custom-tag input rendered a nested <form> inside the
    page's own <form> (invalid HTML, React 19 hard-errors on it during
    hydration) — replaced with a plain input handling Enter directly.
  • The team page and team settings form crashed outright for any team with
    no tags set (team.tags was null, not []) — fixed on both sides: the
    Popplio response no longer sends null for this (see Popplio's
    changelog), and the frontend now defensively falls back to [] too.

Backend integration notes

Some entries above required backend changes: