Changelog
Every release shipped to BoltHub. Major and minor updates lead each version, fixes follow underneath.
- Breaking@bolthub/landing·v0.4.0
feat(i18n): add Turkish, Dutch, Danish, Swedish, French, Finnish, Greek locales
- Dashboard and landing now ship
tr,nl,da,sv,fr,fi,elmessage catalogs alongsideenandde; bothlocalesarrays extended and language switchers show the new flags and endonym labels - Guild Settings language picker, landing layout message imports, and the shared
CookieConsentLocaleValueunion all accept the new locales - Bot
t()now resolves the seven new languages for event/RSVP/social-alert/music/access-control keys; English fallback unchanged
feat(landing): full SEO overhaul — subpath i18n, hreflang, JSON-LD, dynamic OG, security headers
Multi-block rework that lifts the landing site from cookie-based locale to
crawlable subpath URLs and fills out every piece of metadata that
multilingual SEO needs.- Subpath i18n routing (
as-neededmode): English stays at/, the
other eight locales live at/de,/tr,/nl,/da,/sv,/fr,
/fi,/el. App tree restructured underapp/[locale]/; the language
switcher now navigates vianext-intl's router withuseTransition,
so URL + visible language stay in sync and Google can crawl every
variant. Thenext-intlmiddleware lives atsrc/proxy.ts(Next 16's
successor tomiddleware.ts).
- Metadata foundation: newlib/seo.tswithbuildMetadata/
canonicalUrl/localeUrl/languageAlternateshelpers. Every page
now ships its own canonical, hreflang map, OG, and Twitter card. Legal
pages carrynoindex. Root layout adds author/publisher/keywords/
applicationName/Twitter handles and a dedicatednot-found.tsx/
app/[locale]/not-found.tsxpair.
- Structured data:Organization,WebSite(with Sitelinks
SearchAction),SoftwareApplication,FAQPage(mirrors the visible
FAQ via the same i18n keys), andBreadcrumbListon legal pages.
- Sitemap: dropped staticpublic/sitemap.xmlfor a dynamic
app/sitemap.tsthat emits one URL per locale with hreflang
alternates and env-aware base URL.
- Resource hints + assets: preconnect/dns-prefetch to Discord CDN,
PWAmanifest.webmanifest, sized apple-touch-icon, Navbar logo gets
priority+fetchPriority="high"(LCP).
- Security headers: HSTS (prod-only), Permissions-Policy,
Cross-Origin-Opener-Policy, plus CSP extension to allow
cdn.discordapp.comimages so event-page avatars actually load.
- Dynamic OG image:app/[locale]/opengraph-image.tsxvia
next/og'sImageResponsereplaces the static/og-image.jpgfor
per-page branded cards.
- New i18nnotFoundnamespace across all nine locales.feat(landing): SEO polish + public changelog route
Builds on the SEO overhaul (subpath i18n, hreflang, JSON-LD, dynamic OG)
with a public changelog page and the polish items the prior PR deferred.- `/changelog` route (locale-aware, indexable). Reads each app's
CHANGELOG.mdfrom disk at build time, parses Changeset section
headers, and renders one card per release with badges per level
(Breaking / Feature / Fix). Mechanical "Updated dependencies" lines
are dropped. Anchor IDs per entry (#landing-0.2.5) make releases
deep-linkable. Footer link in Resources, sitemap entry, breadcrumb
JSON-LD. Auto-syncs at every release because the parser re-reads
freshCHANGELOG.mdon each build.
- RSS 2.0 feed at/changelog/feed.xml, surfaced via
<link rel="alternate" type="application/rss+xml">.
- Article JSON-LD per release.datePublishedresolved at build
time by walkinggit log -p --followon eachCHANGELOG.mdand
picking the commit that first introduced the version header.
Releases without resolvable history are emitted without a date.
- Per-locale OG cards.app/[locale]/opengraph-image.tsxreads
the route param and pulls headline + tagline from the matching
og.*namespace inmessages/{locale}.json.
- `favicon.ico` generated fromlogo-color.png(multi-size
16/32/48/64/128/256, trimmed + zoomed to fill canvas).
- Differentiated `changeFrequency` + `priority` insitemap.ts
(RouteSpecrecords, translated variants × 0.9 multiplier).
- `<noscript>` Hero fallback for non-JS crawlers.
- Footer icons for every link (IoGridOutline,IoPricetagOutline,
IoSpeedometerOutline,IoSparklesOutline,IoDocumentTextOutline,
IoShieldCheckmarkOutline,IoBusinessOutline).
- Hash-anchor routing fix. Footer + Navbar#features/
#pricingstyle links now route throughIntlLink href="/#…"so
they work from any sub-route, not only from the home page.
- Locale switch keeps scroll position (router.replace(..., { scroll: false })).
- Smooth-scroll opt-in viadata-scroll-behavior="smooth"on
<html>so route transitions are instant while in-page hash jumps
stay smooth.
- `LegalPageTemplate` server/client boundary fix. Icon prop now
takes a pre-renderedReactNodeinstead of a component reference,
which is not serializable across the boundary.
- `scripts/build.sh` mirrorsscripts/dev.sh upbut boots every
app in production-build mode (separatebolt-buildtmux session,
green status accent) for realistic perf/SEO checks.
- `docs/SEO.md` captures the full surface — what is shipped, the
decisions behind it, and the remaining open items. - Dashboard and landing now ship
- Update@bolthub/landing·v0.4.0
feat(landing): TOC sidebar on /changelog with grouped per-app navigation
Two-column layout on
lg+: sticky TOC sidebar (left) lists every release
grouped by app with a level-coloured dot per version, anchored at the
matching#{app}-{version}IDs in the main content. Belowlgthe TOC
collapses into a<details>"Jump to" summary so the page stays
mobile-friendly without hiding content from crawlers.Adds
changelog.tocTitle+changelog.jumpToLabelkeys across all
nine locales.perf(hero): lazy-hydrate LogoBeam canvas via dynamic import
Splits the ~380-line Canvas 2D animation out of the initial Hero JS
chunk. The heading, subtitle, version badge, and CTAs hydrate first;
the decorative beam canvas (aria-hidden) streams in afterwards as its
own chunk. Shortens time-to-interactive on the initial visit without
changing the visual output — same DPR cap, same beam count, same
off-screen RAF pause as before.Reduced-motion overhaul: custom
useReducedMotionPrefhook + smooth scroll.useReducedMotion()from motion/react only readsmatchMedia— it ignores both MotionConfig AND our cookie override, so every custom RAF / setInterval / IntersectionObserver-driven animation kept running even after the user toggled "Reduce Motion" in the Cookie Consent panel.New
useReducedMotionPrefhook in@bolthub/ui:- Reactive: reads
html[data-reduced-motion]attribute +prefers-reduced-motionmedia query MutationObserveron the attribute +matchMedia.changelistener- Starts
falseon server + first client render (SSR-safe), reconciles via effect
Swapped into: FlipWords, MovingBorder(+Button/BorderGlow), Lamp, TracingBeam, FloatingHexagons, Navbar EdgeBeam, LogoBeam, Reveal, SocialProof AnimatedCounter.
Cookie Consent toggle now writes the cookie and reloads so both the
<html data-reduced-motion>attribute AND MotionConfig reducedMotion context come from the same server render.Landing:
html { scroll-behavior: smooth }with reduced-motion override toauto. Tailwindmotion-reduce:hiddenextended to respect the cookie-drivenhtml[data-reduced-motion="on"]selector. - Reactive: reads
- Breaking@bolthub/api·v0.3.0
Manager Roles + granular per-module role access.
The landing page has been promising Manager Roles + Granular Dashboard Access Control for a while; the
managerRolestable existed butcheckGuildAccess()never consulted it. This changeset wires that promise end-to-end and extends it with per-module role grants that cover both dashboard configuration and Discord-side usage.Permission hierarchy (final):
1. Guild Owner — unconstrained; only owner can edit the Manager Roles list.
2. Global Manager —members.isManager = trueor any Discord role inmanagerRoles. Full dashboard + module access.
3. Module Configure Grant — role inmodule_role_grantswithkind = 'configure'. May edit that module's dashboard settings + manage itsusegrants.
4. Module Use Grant — role inmodule_role_grantswithkind = 'use'. May use the module in Discord (slash commands, buttons, WS controls, public player controls). If a module has zerousegrants, it defaults to open for every guild member.New schema:
module_role_grants(guildId,module,roleId,kind) with unique + composite indexes.New API routes:
GET /guilds/:guildId/access— self-access resolver (returns empty on no-access rather than 403 so dashboard can render a useful state).GET|POST|DELETE /guilds/:guildId/manager-roles(POST/DELETE owner-only).GET|POST|DELETE /guilds/:guildId/modules/:module/grants.
Bot: new Redis channel
bolthub:guilds:get-member-roles(API-side cached 60s).interactionCreaterunscheckModuleUsebefore executing music buttons and RSVP buttons. NewguildMemberUpdatelistener invalidates the member-roles cache key on role diff so a demoted user loses access immediately (60s TTL is only the safety-net).API: new
requireModuleConfigure(module)middleware replacesrequireGuildAccesson feature routes (embeds,events,social-alerts,musicconfig). Music WS handshake now gates onaccess.useModules.has('music').Dashboard: Settings page gains a Manager Roles section (owner-only mutation). Each live-module page gains a Module Access panel (configure / use grants). Sidebar filters nav items for module-configure grantees who aren't blanket managers.
Docs:
docs/schema.md,ARCHITECTURE.md,BUILD_ORDER.md,redis-protocol.mdupdated.i18n: new
managerRoles+accessnamespaces inapps/dashboard/messages/{en,de}.json; newaccess.*keys inapps/bot/src/lib/i18n.ts(en + de).Known limitations (follow-ups): orphaned grants (role deleted in Discord) are not cleaned up; configure-grantees without Discord
MANAGE_GUILDdon't yet appear in the/guildsswitcher. - Update@bolthub/api·v0.3.0
Channel-permission awareness across the bot/API/dashboard stack.
The dashboard's ChannelSelect used to show every channel the bot could
_see_, not every channel the bot could _post in_. Admins could pick a
channel where the bot silently failed to send — especially painful on
the onboarding Update-Channel picker where that misconfig wasn't caught
until the first broadcast went out.Fix is full-stack so it can't be bypassed from any surface:
- Bot (
apps/bot/src/modules/guilds/guilds.handler.ts):
bolthub:guilds:get-channelsnow resolves the bot member's real
permission overwrites per channel and returnswritable+speakable
booleans alongside the existing shape.writablerequires
ViewChannel+SendMessages+EmbedLinks;speakablerequires
ViewChannel+Connect+Speak. Voice-type channels report
writable: falseon purpose — BoltHub never posts embeds into a
voice-text buffer.- API (
apps/api/src/lib/channel-permissions.ts): new
assertChannelCapability(guildId, channelId, need)helper. Throws
ConflictError(409) with a user-facing message listing the missing
Discord permissions, orNotFoundErrorif the channel id is unknown
to the bot. Wired into every save-path that persists achannelId:-
PUT /guilds/:guildId/broadcast-subscription
-POST /guilds/:guildId/embeds,PATCH /:embedId,POST /:embedId/send
-POST /guilds/:guildId/events,PATCH /:eventId,
POST /schedules,PATCH /schedules/:scheduleId
-POST /guilds/:guildId/social-alerts,PATCH /:alertIdguilds.controllerGET /channelsinline response type updated to
match the new shape.- Dashboard (
apps/dashboard/src/components/ChannelSelect.tsx,
hooks/use-guild-channels.ts):-
DiscordChannelinterface gainswritable+speakable.
- NewuseRefreshGuildChannels(guildId)hook invalidates the cache so
admins who just granted permissions can pick the channel without a
full page reload.
-ChannelSelectrenders inaccessible channels disabled with a
warning icon, a(Kein Zugriff)badge inline, and an animate-ui
Tooltip ("Bot lacks permission in this channel — grant View
Channel, Send Messages, and Embed Links…") on hover. Radix's
defaultpointer-events: noneon disabled items is overridden with
!pointer-events-autoso hover events reach the Tooltip trigger;
Radix still refuses selection viaaria-disabledinternally so the
gate can't be bypassed through CSS.
-showRefreshprop (defaulttrue) mounts a refresh icon-button
next to the dropdown that spins while fetching.- i18n: three new
commonkeys in bothen.json+de.json—
channelNoAccess(full tooltip text),channelNoAccessShort
("No access" / "Kein Zugriff" inline badge), and
refreshChannelPermissions(refresh button label).- Docs:
docs/redis-protocol.mdbolthub:guilds:get-channels
section updated with the new response shape.docs/brand.md
ChannelSelectsection expanded with the permission-handling rules
and the API-side guard reference.Verified: typecheck + lint green across all 7 packages. Bot + API
restarted undertsx watch. Channel dropdown now disables
non-writable channels with tooltip.Fix configure-grantees editing module grants + self-role preservation.
module_role_grantsroutes were guarded byrequireGuildAccess
(blanket managers only). Configure-grantees therefore couldn't even
GET the grant list for their module — the Use Roles dropdown on the
Access Panel rendered but stayed empty, and the Add-button 403'd.Routes now use
requireAnyGuildAccess; service-level
assertMayMutatealready scoped mutations correctly (configure-only
for blanket, use-only for configure-grantees).Added
assertNotSelfRole: non-blanket callers cannot add or remove
grants for one of their own Discord roles. Without this a configure-
grantee could revoke the very role that grants them access and lock
out the whole role. Owners + global-managers bypass./guilds/:guildId/accessnow returns the caller'sroleIdsso the
dashboard hides the remove-button + disables the add-button for
self-role grants. Self-role grants show a "Your role" badge so it's
obvious why they're read-only.Fix: dashboard Play button dead after Stop / Leave.
The persisted-queue work landed but the dashboard had no way to
actually trigger a resume — the center Play button was wired to
onResume(unpause) and disabled wheneverstate.status === "idle",
which is exactly the state Stop + Leave produce.New WS command
resume-queue→ bot channelbolthub:music:resume-queue:- Ensures player (auto-join caller's voice) + restores Redis snapshot
(if not already loaded) + kicks off playback
- Throws ValidationError when nothing is persisted (UI keeps the
button disabled in that case)Center Play button now branches on status:
playing→ Pausepaused→ Resume (unpause existing player)idle+ queue has tracks → Resume Queue (new path)idle+ queue empty → disabled
Page-width wrapper + HTTP cache hardening.
Dashboard: new
<Page width="narrow|default|wide">component applied
to every/guilds/:guildId/**route. Lists + overview get
max-w-6xl, settings forms getmax-w-3xl, music player + embed
editor getmax-w-none. Player (/player/**) and Admin routes are
intentionally untouched. Picked the component-wrapper approach over
Next route-groups because/embedslist and/embeds/[id]editor
need different widths under the same segment — route-groups don't
support segment-sharing across groups, a wrapper keeps the
decision co-located with the page.API: every response now gets
Cache-Control: private, no-store
unless the handler already set one. Fixes a list-stale-after-mutate
bug where the client invalidated["embeds", guildId]correctly,
but the refetch was served from browser heuristic cache instead of
hitting the API. React Query already owns client-side freshness —
HTTP cache on top was working against it.Guild picker: hide bot-present guilds where user has no BoltHub access.
Previously the
/guildspicker was filtered only by Discord
MANAGE_GUILD, so a user with MANAGE_GUILD but no BoltHub grant saw
the server with a "Manage" CTA that then landed on the No Access page.filterAccessibleGuildIdsnow gates bot-present guilds on owner
status,members.isManager,manager_rolesrole match, or ANY
module_role_grantsrow (configure OR use) that matches a role the
user holds. Bot-absent guilds continue to use the MANAGE_GUILD
filter so invite-flow discovery still works. - Breaking@bolthub/bot·v0.3.0
feat(i18n): add Turkish, Dutch, Danish, Swedish, French, Finnish, Greek locales
- Dashboard and landing now ship
tr,nl,da,sv,fr,fi,elmessage catalogs alongsideenandde; bothlocalesarrays extended and language switchers show the new flags and endonym labels - Guild Settings language picker, landing layout message imports, and the shared
CookieConsentLocaleValueunion all accept the new locales - Bot
t()now resolves the seven new languages for event/RSVP/social-alert/music/access-control keys; English fallback unchanged
Manager Roles + granular per-module role access.
The landing page has been promising Manager Roles + Granular Dashboard Access Control for a while; the
managerRolestable existed butcheckGuildAccess()never consulted it. This changeset wires that promise end-to-end and extends it with per-module role grants that cover both dashboard configuration and Discord-side usage.Permission hierarchy (final):
1. Guild Owner — unconstrained; only owner can edit the Manager Roles list.
2. Global Manager —members.isManager = trueor any Discord role inmanagerRoles. Full dashboard + module access.
3. Module Configure Grant — role inmodule_role_grantswithkind = 'configure'. May edit that module's dashboard settings + manage itsusegrants.
4. Module Use Grant — role inmodule_role_grantswithkind = 'use'. May use the module in Discord (slash commands, buttons, WS controls, public player controls). If a module has zerousegrants, it defaults to open for every guild member.New schema:
module_role_grants(guildId,module,roleId,kind) with unique + composite indexes.New API routes:
GET /guilds/:guildId/access— self-access resolver (returns empty on no-access rather than 403 so dashboard can render a useful state).GET|POST|DELETE /guilds/:guildId/manager-roles(POST/DELETE owner-only).GET|POST|DELETE /guilds/:guildId/modules/:module/grants.
Bot: new Redis channel
bolthub:guilds:get-member-roles(API-side cached 60s).interactionCreaterunscheckModuleUsebefore executing music buttons and RSVP buttons. NewguildMemberUpdatelistener invalidates the member-roles cache key on role diff so a demoted user loses access immediately (60s TTL is only the safety-net).API: new
requireModuleConfigure(module)middleware replacesrequireGuildAccesson feature routes (embeds,events,social-alerts,musicconfig). Music WS handshake now gates onaccess.useModules.has('music').Dashboard: Settings page gains a Manager Roles section (owner-only mutation). Each live-module page gains a Module Access panel (configure / use grants). Sidebar filters nav items for module-configure grantees who aren't blanket managers.
Docs:
docs/schema.md,ARCHITECTURE.md,BUILD_ORDER.md,redis-protocol.mdupdated.i18n: new
managerRoles+accessnamespaces inapps/dashboard/messages/{en,de}.json; newaccess.*keys inapps/bot/src/lib/i18n.ts(en + de).Known limitations (follow-ups): orphaned grants (role deleted in Discord) are not cleaned up; configure-grantees without Discord
MANAGE_GUILDdon't yet appear in the/guildsswitcher.Persist music queue across bot disconnects.
Live queue (current track + upcoming + volume + loop + shuffle) is
mirrored to Redis underbolthub:music:queue:{guildId}with a 7-day
TTL. The queue now survives:- Idle timeout (5min alone in voice) — bot leaves but queue stays
- User presses Stop — bot leaves, queue stays, "Clear Queue" button
wipes it explicitly
- User presses Leave — same as Stop
- Voice-channel switch (Join other VC) — queue carries over
- Bot process restart / crash
- Lavalink node crashCleared only on:
- Natural queue end (
playerEmptywith nothing left)
- User clicks Clear Queue button (already present in MusicQueue UI —
backend now also wipes Redis)
- User triggersqueue:replace-from-playlist(old queue replaced)Implementation:
-
persistQueueState(player)serializes the live state; wired into
KazagumoplayerStart/playerEndevents and every mutating
handler (queue-add / remove / reorder, volume, loop, shuffle).
-restorePersistedQueue(player, requestedBy)runs immediately after
everycreatePlayer()in handlePlay / handleQueueAdd / handleJoin.
Tracks are re-searched through Lavalink by URI (slower than
decoding cached blobs, but robust across Lavalink upgrades and
reuses the existing search-with-fallback pattern).
- Current-track position is NOT preserved; resume starts the track
from 0s. Simpler, 99% UX-OK.
-handleStatefalls back to the Redis snapshot when no live player
exists, so the dashboard keeps showing the persisted queue after
Stop / Leave / idle-timeout / restart — users see what will resume
when they press Play.Redis itself uses RDB snapshots + volume mount, so Redis restarts
don't drop state either. - Dashboard and landing now ship
- Update@bolthub/bot·v0.3.0
Channel-permission awareness across the bot/API/dashboard stack.
The dashboard's ChannelSelect used to show every channel the bot could
_see_, not every channel the bot could _post in_. Admins could pick a
channel where the bot silently failed to send — especially painful on
the onboarding Update-Channel picker where that misconfig wasn't caught
until the first broadcast went out.Fix is full-stack so it can't be bypassed from any surface:
- Bot (
apps/bot/src/modules/guilds/guilds.handler.ts):
bolthub:guilds:get-channelsnow resolves the bot member's real
permission overwrites per channel and returnswritable+speakable
booleans alongside the existing shape.writablerequires
ViewChannel+SendMessages+EmbedLinks;speakablerequires
ViewChannel+Connect+Speak. Voice-type channels report
writable: falseon purpose — BoltHub never posts embeds into a
voice-text buffer.- API (
apps/api/src/lib/channel-permissions.ts): new
assertChannelCapability(guildId, channelId, need)helper. Throws
ConflictError(409) with a user-facing message listing the missing
Discord permissions, orNotFoundErrorif the channel id is unknown
to the bot. Wired into every save-path that persists achannelId:-
PUT /guilds/:guildId/broadcast-subscription
-POST /guilds/:guildId/embeds,PATCH /:embedId,POST /:embedId/send
-POST /guilds/:guildId/events,PATCH /:eventId,
POST /schedules,PATCH /schedules/:scheduleId
-POST /guilds/:guildId/social-alerts,PATCH /:alertIdguilds.controllerGET /channelsinline response type updated to
match the new shape.- Dashboard (
apps/dashboard/src/components/ChannelSelect.tsx,
hooks/use-guild-channels.ts):-
DiscordChannelinterface gainswritable+speakable.
- NewuseRefreshGuildChannels(guildId)hook invalidates the cache so
admins who just granted permissions can pick the channel without a
full page reload.
-ChannelSelectrenders inaccessible channels disabled with a
warning icon, a(Kein Zugriff)badge inline, and an animate-ui
Tooltip ("Bot lacks permission in this channel — grant View
Channel, Send Messages, and Embed Links…") on hover. Radix's
defaultpointer-events: noneon disabled items is overridden with
!pointer-events-autoso hover events reach the Tooltip trigger;
Radix still refuses selection viaaria-disabledinternally so the
gate can't be bypassed through CSS.
-showRefreshprop (defaulttrue) mounts a refresh icon-button
next to the dropdown that spins while fetching.- i18n: three new
commonkeys in bothen.json+de.json—
channelNoAccess(full tooltip text),channelNoAccessShort
("No access" / "Kein Zugriff" inline badge), and
refreshChannelPermissions(refresh button label).- Docs:
docs/redis-protocol.mdbolthub:guilds:get-channels
section updated with the new response shape.docs/brand.md
ChannelSelectsection expanded with the permission-handling rules
and the API-side guard reference.Verified: typecheck + lint green across all 7 packages. Bot + API
restarted undertsx watch. Channel dropdown now disables
non-writable channels with tooltip.Fix: dashboard Play button dead after Stop / Leave.
The persisted-queue work landed but the dashboard had no way to
actually trigger a resume — the center Play button was wired to
onResume(unpause) and disabled wheneverstate.status === "idle",
which is exactly the state Stop + Leave produce.New WS command
resume-queue→ bot channelbolthub:music:resume-queue:- Ensures player (auto-join caller's voice) + restores Redis snapshot
(if not already loaded) + kicks off playback
- Throws ValidationError when nothing is persisted (UI keeps the
button disabled in that case)Center Play button now branches on status:
playing→ Pausepaused→ Resume (unpause existing player)idle+ queue has tracks → Resume Queue (new path)idle+ queue empty → disabled
- Breaking@bolthub/dashboard·v0.3.0
feat(i18n): add Turkish, Dutch, Danish, Swedish, French, Finnish, Greek locales
- Dashboard and landing now ship
tr,nl,da,sv,fr,fi,elmessage catalogs alongsideenandde; bothlocalesarrays extended and language switchers show the new flags and endonym labels - Guild Settings language picker, landing layout message imports, and the shared
CookieConsentLocaleValueunion all accept the new locales - Bot
t()now resolves the seven new languages for event/RSVP/social-alert/music/access-control keys; English fallback unchanged
Manager Roles + granular per-module role access.
The landing page has been promising Manager Roles + Granular Dashboard Access Control for a while; the
managerRolestable existed butcheckGuildAccess()never consulted it. This changeset wires that promise end-to-end and extends it with per-module role grants that cover both dashboard configuration and Discord-side usage.Permission hierarchy (final):
1. Guild Owner — unconstrained; only owner can edit the Manager Roles list.
2. Global Manager —members.isManager = trueor any Discord role inmanagerRoles. Full dashboard + module access.
3. Module Configure Grant — role inmodule_role_grantswithkind = 'configure'. May edit that module's dashboard settings + manage itsusegrants.
4. Module Use Grant — role inmodule_role_grantswithkind = 'use'. May use the module in Discord (slash commands, buttons, WS controls, public player controls). If a module has zerousegrants, it defaults to open for every guild member.New schema:
module_role_grants(guildId,module,roleId,kind) with unique + composite indexes.New API routes:
GET /guilds/:guildId/access— self-access resolver (returns empty on no-access rather than 403 so dashboard can render a useful state).GET|POST|DELETE /guilds/:guildId/manager-roles(POST/DELETE owner-only).GET|POST|DELETE /guilds/:guildId/modules/:module/grants.
Bot: new Redis channel
bolthub:guilds:get-member-roles(API-side cached 60s).interactionCreaterunscheckModuleUsebefore executing music buttons and RSVP buttons. NewguildMemberUpdatelistener invalidates the member-roles cache key on role diff so a demoted user loses access immediately (60s TTL is only the safety-net).API: new
requireModuleConfigure(module)middleware replacesrequireGuildAccesson feature routes (embeds,events,social-alerts,musicconfig). Music WS handshake now gates onaccess.useModules.has('music').Dashboard: Settings page gains a Manager Roles section (owner-only mutation). Each live-module page gains a Module Access panel (configure / use grants). Sidebar filters nav items for module-configure grantees who aren't blanket managers.
Docs:
docs/schema.md,ARCHITECTURE.md,BUILD_ORDER.md,redis-protocol.mdupdated.i18n: new
managerRoles+accessnamespaces inapps/dashboard/messages/{en,de}.json; newaccess.*keys inapps/bot/src/lib/i18n.ts(en + de).Known limitations (follow-ups): orphaned grants (role deleted in Discord) are not cleaned up; configure-grantees without Discord
MANAGE_GUILDdon't yet appear in the/guildsswitcher. - Dashboard and landing now ship
- Update@bolthub/dashboard·v0.3.0
Move Module Access Panel into module settings route.
Access configuration now lives at
/guilds/:guildId/modules/:module/settingsalongside the module's
other settings, instead of being a card on the module's main list
page. Keeps access + module config in one place and declutters the
embeds + music landing pages.Channel-permission awareness across the bot/API/dashboard stack.
The dashboard's ChannelSelect used to show every channel the bot could
_see_, not every channel the bot could _post in_. Admins could pick a
channel where the bot silently failed to send — especially painful on
the onboarding Update-Channel picker where that misconfig wasn't caught
until the first broadcast went out.Fix is full-stack so it can't be bypassed from any surface:
- Bot (
apps/bot/src/modules/guilds/guilds.handler.ts):
bolthub:guilds:get-channelsnow resolves the bot member's real
permission overwrites per channel and returnswritable+speakable
booleans alongside the existing shape.writablerequires
ViewChannel+SendMessages+EmbedLinks;speakablerequires
ViewChannel+Connect+Speak. Voice-type channels report
writable: falseon purpose — BoltHub never posts embeds into a
voice-text buffer.- API (
apps/api/src/lib/channel-permissions.ts): new
assertChannelCapability(guildId, channelId, need)helper. Throws
ConflictError(409) with a user-facing message listing the missing
Discord permissions, orNotFoundErrorif the channel id is unknown
to the bot. Wired into every save-path that persists achannelId:-
PUT /guilds/:guildId/broadcast-subscription
-POST /guilds/:guildId/embeds,PATCH /:embedId,POST /:embedId/send
-POST /guilds/:guildId/events,PATCH /:eventId,
POST /schedules,PATCH /schedules/:scheduleId
-POST /guilds/:guildId/social-alerts,PATCH /:alertIdguilds.controllerGET /channelsinline response type updated to
match the new shape.- Dashboard (
apps/dashboard/src/components/ChannelSelect.tsx,
hooks/use-guild-channels.ts):-
DiscordChannelinterface gainswritable+speakable.
- NewuseRefreshGuildChannels(guildId)hook invalidates the cache so
admins who just granted permissions can pick the channel without a
full page reload.
-ChannelSelectrenders inaccessible channels disabled with a
warning icon, a(Kein Zugriff)badge inline, and an animate-ui
Tooltip ("Bot lacks permission in this channel — grant View
Channel, Send Messages, and Embed Links…") on hover. Radix's
defaultpointer-events: noneon disabled items is overridden with
!pointer-events-autoso hover events reach the Tooltip trigger;
Radix still refuses selection viaaria-disabledinternally so the
gate can't be bypassed through CSS.
-showRefreshprop (defaulttrue) mounts a refresh icon-button
next to the dropdown that spins while fetching.- i18n: three new
commonkeys in bothen.json+de.json—
channelNoAccess(full tooltip text),channelNoAccessShort
("No access" / "Kein Zugriff" inline badge), and
refreshChannelPermissions(refresh button label).- Docs:
docs/redis-protocol.mdbolthub:guilds:get-channels
section updated with the new response shape.docs/brand.md
ChannelSelectsection expanded with the permission-handling rules
and the API-side guard reference.Verified: typecheck + lint green across all 7 packages. Bot + API
restarted undertsx watch. Channel dropdown now disables
non-writable channels with tooltip.Fix configure-grantees editing module grants + self-role preservation.
module_role_grantsroutes were guarded byrequireGuildAccess
(blanket managers only). Configure-grantees therefore couldn't even
GET the grant list for their module — the Use Roles dropdown on the
Access Panel rendered but stayed empty, and the Add-button 403'd.Routes now use
requireAnyGuildAccess; service-level
assertMayMutatealready scoped mutations correctly (configure-only
for blanket, use-only for configure-grantees).Added
assertNotSelfRole: non-blanket callers cannot add or remove
grants for one of their own Discord roles. Without this a configure-
grantee could revoke the very role that grants them access and lock
out the whole role. Owners + global-managers bypass./guilds/:guildId/accessnow returns the caller'sroleIdsso the
dashboard hides the remove-button + disables the add-button for
self-role grants. Self-role grants show a "Your role" badge so it's
obvious why they're read-only.Fix: dashboard Play button dead after Stop / Leave.
The persisted-queue work landed but the dashboard had no way to
actually trigger a resume — the center Play button was wired to
onResume(unpause) and disabled wheneverstate.status === "idle",
which is exactly the state Stop + Leave produce.New WS command
resume-queue→ bot channelbolthub:music:resume-queue:- Ensures player (auto-join caller's voice) + restores Redis snapshot
(if not already loaded) + kicks off playback
- Throws ValidationError when nothing is persisted (UI keeps the
button disabled in that case)Center Play button now branches on status:
playing→ Pausepaused→ Resume (unpause existing player)idle+ queue has tracks → Resume Queue (new path)idle+ queue empty → disabled
Page-width wrapper + HTTP cache hardening.
Dashboard: new
<Page width="narrow|default|wide">component applied
to every/guilds/:guildId/**route. Lists + overview get
max-w-6xl, settings forms getmax-w-3xl, music player + embed
editor getmax-w-none. Player (/player/**) and Admin routes are
intentionally untouched. Picked the component-wrapper approach over
Next route-groups because/embedslist and/embeds/[id]editor
need different widths under the same segment — route-groups don't
support segment-sharing across groups, a wrapper keeps the
decision co-located with the page.API: every response now gets
Cache-Control: private, no-store
unless the handler already set one. Fixes a list-stale-after-mutate
bug where the client invalidated["embeds", guildId]correctly,
but the refetch was served from browser heuristic cache instead of
hitting the API. React Query already owns client-side freshness —
HTTP cache on top was working against it.fix(sidebar): drive active/hover indicator with motion layoutId
Replaces the ref-based
getBoundingClientRect+useEffectmeasurement
inSidebarwith motion/react'slayoutIdFLIP. The indicator pill and
orange active bracket now live inside the active<li>itself, so they
reposition automatically whenever nav items resize — including when
labels wrap to two lines on locale switch, when fonts finish loading, or
when the Modules-enabled dot appears/disappears. Previously the rect was
only recomputed onactiveKey/hoveredKey/pathnamechanges, so a
locale swap left the indicator stranded at the old coordinates. - Breaking@bolthub/landing·v0.3.0
feat(i18n): add Turkish, Dutch, Danish, Swedish, French, Finnish, Greek locales
- Dashboard and landing now ship
tr,nl,da,sv,fr,fi,elmessage catalogs alongsideenandde; bothlocalesarrays extended and language switchers show the new flags and endonym labels - Guild Settings language picker, landing layout message imports, and the shared
CookieConsentLocaleValueunion all accept the new locales - Bot
t()now resolves the seven new languages for event/RSVP/social-alert/music/access-control keys; English fallback unchanged
feat(landing): full SEO overhaul — subpath i18n, hreflang, JSON-LD, dynamic OG, security headers
Multi-block rework that lifts the landing site from cookie-based locale to
crawlable subpath URLs and fills out every piece of metadata that
multilingual SEO needs.- Subpath i18n routing (
as-neededmode): English stays at/, the
other eight locales live at/de,/tr,/nl,/da,/sv,/fr,
/fi,/el. App tree restructured underapp/[locale]/; the language
switcher now navigates vianext-intl's router withuseTransition,
so URL + visible language stay in sync and Google can crawl every
variant. Thenext-intlmiddleware lives atsrc/proxy.ts(Next 16's
successor tomiddleware.ts).
- Metadata foundation: newlib/seo.tswithbuildMetadata/
canonicalUrl/localeUrl/languageAlternateshelpers. Every page
now ships its own canonical, hreflang map, OG, and Twitter card. Legal
pages carrynoindex. Root layout adds author/publisher/keywords/
applicationName/Twitter handles and a dedicatednot-found.tsx/
app/[locale]/not-found.tsxpair.
- Structured data:Organization,WebSite(with Sitelinks
SearchAction),SoftwareApplication,FAQPage(mirrors the visible
FAQ via the same i18n keys), andBreadcrumbListon legal pages.
- Sitemap: dropped staticpublic/sitemap.xmlfor a dynamic
app/sitemap.tsthat emits one URL per locale with hreflang
alternates and env-aware base URL.
- Resource hints + assets: preconnect/dns-prefetch to Discord CDN,
PWAmanifest.webmanifest, sized apple-touch-icon, Navbar logo gets
priority+fetchPriority="high"(LCP).
- Security headers: HSTS (prod-only), Permissions-Policy,
Cross-Origin-Opener-Policy, plus CSP extension to allow
cdn.discordapp.comimages so event-page avatars actually load.
- Dynamic OG image:app/[locale]/opengraph-image.tsxvia
next/og'sImageResponsereplaces the static/og-image.jpgfor
per-page branded cards.
- New i18nnotFoundnamespace across all nine locales.feat(landing): SEO polish + public changelog route
Builds on the SEO overhaul (subpath i18n, hreflang, JSON-LD, dynamic OG)
with a public changelog page and the polish items the prior PR deferred.- `/changelog` route (locale-aware, indexable). Reads each app's
CHANGELOG.mdfrom disk at build time, parses Changeset section
headers, and renders one card per release with badges per level
(Breaking / Feature / Fix). Mechanical "Updated dependencies" lines
are dropped. Anchor IDs per entry (#landing-0.2.5) make releases
deep-linkable. Footer link in Resources, sitemap entry, breadcrumb
JSON-LD. Auto-syncs at every release because the parser re-reads
freshCHANGELOG.mdon each build.
- RSS 2.0 feed at/changelog/feed.xml, surfaced via
<link rel="alternate" type="application/rss+xml">.
- Article JSON-LD per release.datePublishedresolved at build
time by walkinggit log -p --followon eachCHANGELOG.mdand
picking the commit that first introduced the version header.
Releases without resolvable history are emitted without a date.
- Per-locale OG cards.app/[locale]/opengraph-image.tsxreads
the route param and pulls headline + tagline from the matching
og.*namespace inmessages/{locale}.json.
- `favicon.ico` generated fromlogo-color.png(multi-size
16/32/48/64/128/256, trimmed + zoomed to fill canvas).
- Differentiated `changeFrequency` + `priority` insitemap.ts
(RouteSpecrecords, translated variants × 0.9 multiplier).
- `<noscript>` Hero fallback for non-JS crawlers.
- Footer icons for every link (IoGridOutline,IoPricetagOutline,
IoSpeedometerOutline,IoSparklesOutline,IoDocumentTextOutline,
IoShieldCheckmarkOutline,IoBusinessOutline).
- Hash-anchor routing fix. Footer + Navbar#features/
#pricingstyle links now route throughIntlLink href="/#…"so
they work from any sub-route, not only from the home page.
- Locale switch keeps scroll position (router.replace(..., { scroll: false })).
- Smooth-scroll opt-in viadata-scroll-behavior="smooth"on
<html>so route transitions are instant while in-page hash jumps
stay smooth.
- `LegalPageTemplate` server/client boundary fix. Icon prop now
takes a pre-renderedReactNodeinstead of a component reference,
which is not serializable across the boundary.
- `scripts/build.sh` mirrorsscripts/dev.sh upbut boots every
app in production-build mode (separatebolt-buildtmux session,
green status accent) for realistic perf/SEO checks.
- `docs/SEO.md` captures the full surface — what is shipped, the
decisions behind it, and the remaining open items. - Dashboard and landing now ship
- Update@bolthub/landing·v0.3.0
perf(hero): lazy-hydrate LogoBeam canvas via dynamic import
Splits the ~380-line Canvas 2D animation out of the initial Hero JS
chunk. The heading, subtitle, version badge, and CTAs hydrate first;
the decorative beam canvas (aria-hidden) streams in afterwards as its
own chunk. Shortens time-to-interactive on the initial visit without
changing the visual output — same DPR cap, same beam count, same
off-screen RAF pause as before.Reduced-motion overhaul: custom
useReducedMotionPrefhook + smooth scroll.useReducedMotion()from motion/react only readsmatchMedia— it ignores both MotionConfig AND our cookie override, so every custom RAF / setInterval / IntersectionObserver-driven animation kept running even after the user toggled "Reduce Motion" in the Cookie Consent panel.New
useReducedMotionPrefhook in@bolthub/ui:- Reactive: reads
html[data-reduced-motion]attribute +prefers-reduced-motionmedia query MutationObserveron the attribute +matchMedia.changelistener- Starts
falseon server + first client render (SSR-safe), reconciles via effect
Swapped into: FlipWords, MovingBorder(+Button/BorderGlow), Lamp, TracingBeam, FloatingHexagons, Navbar EdgeBeam, LogoBeam, Reveal, SocialProof AnimatedCounter.
Cookie Consent toggle now writes the cookie and reloads so both the
<html data-reduced-motion>attribute AND MotionConfig reducedMotion context come from the same server render.Landing:
html { scroll-behavior: smooth }with reduced-motion override toauto. Tailwindmotion-reduce:hiddenextended to respect the cookie-drivenhtml[data-reduced-motion="on"]selector. - Reactive: reads
- Update@bolthub/landing·v0.2.4
Gate events + social_alerts as "coming soon" until finalized.
-
@bolthub/types: newLIVE_MODULESset +isModuleLive()helper —
single source of truth for which modules can be enabled. Currently
embeds+musiconly.
-@bolthub/api:modules.service.updateModulethrowsForbiddenError
when enabling a non-live module, even if the plan would allow it.
-@bolthub/dashboard: Sidebar + modules page consumeisModuleLive
instead of a local hardcoded set.ModuleGateshows a dedicated
"Coming Soon" empty state (no Activate button) for non-live modules.
New i18n keysmodules.comingSoonTitle+comingSoonDescription
(en + de).
-@bolthub/landing: Features section marksevents+socialAlerts
ascomingSoon; live modules (embeds,music) lead the grid. - Breaking@bolthub/api·v0.2.0
Manager Roles + granular per-module role access.
The landing page has been promising Manager Roles + Granular Dashboard Access Control for a while; the
managerRolestable existed butcheckGuildAccess()never consulted it. This changeset wires that promise end-to-end and extends it with per-module role grants that cover both dashboard configuration and Discord-side usage.Permission hierarchy (final):
1. Guild Owner — unconstrained; only owner can edit the Manager Roles list.
2. Global Manager —members.isManager = trueor any Discord role inmanagerRoles. Full dashboard + module access.
3. Module Configure Grant — role inmodule_role_grantswithkind = 'configure'. May edit that module's dashboard settings + manage itsusegrants.
4. Module Use Grant — role inmodule_role_grantswithkind = 'use'. May use the module in Discord (slash commands, buttons, WS controls, public player controls). If a module has zerousegrants, it defaults to open for every guild member.New schema:
module_role_grants(guildId,module,roleId,kind) with unique + composite indexes.New API routes:
GET /guilds/:guildId/access— self-access resolver (returns empty on no-access rather than 403 so dashboard can render a useful state).GET|POST|DELETE /guilds/:guildId/manager-roles(POST/DELETE owner-only).GET|POST|DELETE /guilds/:guildId/modules/:module/grants.
Bot: new Redis channel
bolthub:guilds:get-member-roles(API-side cached 60s).interactionCreaterunscheckModuleUsebefore executing music buttons and RSVP buttons. NewguildMemberUpdatelistener invalidates the member-roles cache key on role diff so a demoted user loses access immediately (60s TTL is only the safety-net).API: new
requireModuleConfigure(module)middleware replacesrequireGuildAccesson feature routes (embeds,events,social-alerts,musicconfig). Music WS handshake now gates onaccess.useModules.has('music').Dashboard: Settings page gains a Manager Roles section (owner-only mutation). Each live-module page gains a Module Access panel (configure / use grants). Sidebar filters nav items for module-configure grantees who aren't blanket managers.
Docs:
docs/schema.md,ARCHITECTURE.md,BUILD_ORDER.md,redis-protocol.mdupdated.i18n: new
managerRoles+accessnamespaces inapps/dashboard/messages/{en,de}.json; newaccess.*keys inapps/bot/src/lib/i18n.ts(en + de).Known limitations (follow-ups): orphaned grants (role deleted in Discord) are not cleaned up; configure-grantees without Discord
MANAGE_GUILDdon't yet appear in the/guildsswitcher. - Update@bolthub/api·v0.2.0
Channel-permission awareness across the bot/API/dashboard stack.
The dashboard's ChannelSelect used to show every channel the bot could
_see_, not every channel the bot could _post in_. Admins could pick a
channel where the bot silently failed to send — especially painful on
the onboarding Update-Channel picker where that misconfig wasn't caught
until the first broadcast went out.Fix is full-stack so it can't be bypassed from any surface:
- Bot (
apps/bot/src/modules/guilds/guilds.handler.ts):
bolthub:guilds:get-channelsnow resolves the bot member's real
permission overwrites per channel and returnswritable+speakable
booleans alongside the existing shape.writablerequires
ViewChannel+SendMessages+EmbedLinks;speakablerequires
ViewChannel+Connect+Speak. Voice-type channels report
writable: falseon purpose — BoltHub never posts embeds into a
voice-text buffer.- API (
apps/api/src/lib/channel-permissions.ts): new
assertChannelCapability(guildId, channelId, need)helper. Throws
ConflictError(409) with a user-facing message listing the missing
Discord permissions, orNotFoundErrorif the channel id is unknown
to the bot. Wired into every save-path that persists achannelId:-
PUT /guilds/:guildId/broadcast-subscription
-POST /guilds/:guildId/embeds,PATCH /:embedId,POST /:embedId/send
-POST /guilds/:guildId/events,PATCH /:eventId,
POST /schedules,PATCH /schedules/:scheduleId
-POST /guilds/:guildId/social-alerts,PATCH /:alertIdguilds.controllerGET /channelsinline response type updated to
match the new shape.- Dashboard (
apps/dashboard/src/components/ChannelSelect.tsx,
hooks/use-guild-channels.ts):-
DiscordChannelinterface gainswritable+speakable.
- NewuseRefreshGuildChannels(guildId)hook invalidates the cache so
admins who just granted permissions can pick the channel without a
full page reload.
-ChannelSelectrenders inaccessible channels disabled with a
warning icon, a(Kein Zugriff)badge inline, and an animate-ui
Tooltip ("Bot lacks permission in this channel — grant View
Channel, Send Messages, and Embed Links…") on hover. Radix's
defaultpointer-events: noneon disabled items is overridden with
!pointer-events-autoso hover events reach the Tooltip trigger;
Radix still refuses selection viaaria-disabledinternally so the
gate can't be bypassed through CSS.
-showRefreshprop (defaulttrue) mounts a refresh icon-button
next to the dropdown that spins while fetching.- i18n: three new
commonkeys in bothen.json+de.json—
channelNoAccess(full tooltip text),channelNoAccessShort
("No access" / "Kein Zugriff" inline badge), and
refreshChannelPermissions(refresh button label).- Docs:
docs/redis-protocol.mdbolthub:guilds:get-channels
section updated with the new response shape.docs/brand.md
ChannelSelectsection expanded with the permission-handling rules
and the API-side guard reference.Verified: typecheck + lint green across all 7 packages. Bot + API
restarted undertsx watch. Channel dropdown now disables
non-writable channels with tooltip.Fix configure-grantees editing module grants + self-role preservation.
module_role_grantsroutes were guarded byrequireGuildAccess
(blanket managers only). Configure-grantees therefore couldn't even
GET the grant list for their module — the Use Roles dropdown on the
Access Panel rendered but stayed empty, and the Add-button 403'd.Routes now use
requireAnyGuildAccess; service-level
assertMayMutatealready scoped mutations correctly (configure-only
for blanket, use-only for configure-grantees).Added
assertNotSelfRole: non-blanket callers cannot add or remove
grants for one of their own Discord roles. Without this a configure-
grantee could revoke the very role that grants them access and lock
out the whole role. Owners + global-managers bypass./guilds/:guildId/accessnow returns the caller'sroleIdsso the
dashboard hides the remove-button + disables the add-button for
self-role grants. Self-role grants show a "Your role" badge so it's
obvious why they're read-only.Fix: dashboard Play button dead after Stop / Leave.
The persisted-queue work landed but the dashboard had no way to
actually trigger a resume — the center Play button was wired to
onResume(unpause) and disabled wheneverstate.status === "idle",
which is exactly the state Stop + Leave produce.New WS command
resume-queue→ bot channelbolthub:music:resume-queue:- Ensures player (auto-join caller's voice) + restores Redis snapshot
(if not already loaded) + kicks off playback
- Throws ValidationError when nothing is persisted (UI keeps the
button disabled in that case)Center Play button now branches on status:
playing→ Pausepaused→ Resume (unpause existing player)idle+ queue has tracks → Resume Queue (new path)idle+ queue empty → disabled
Page-width wrapper + HTTP cache hardening.
Dashboard: new
<Page width="narrow|default|wide">component applied
to every/guilds/:guildId/**route. Lists + overview get
max-w-6xl, settings forms getmax-w-3xl, music player + embed
editor getmax-w-none. Player (/player/**) and Admin routes are
intentionally untouched. Picked the component-wrapper approach over
Next route-groups because/embedslist and/embeds/[id]editor
need different widths under the same segment — route-groups don't
support segment-sharing across groups, a wrapper keeps the
decision co-located with the page.API: every response now gets
Cache-Control: private, no-store
unless the handler already set one. Fixes a list-stale-after-mutate
bug where the client invalidated["embeds", guildId]correctly,
but the refetch was served from browser heuristic cache instead of
hitting the API. React Query already owns client-side freshness —
HTTP cache on top was working against it.Guild picker: hide bot-present guilds where user has no BoltHub access.
Previously the
/guildspicker was filtered only by Discord
MANAGE_GUILD, so a user with MANAGE_GUILD but no BoltHub grant saw
the server with a "Manage" CTA that then landed on the No Access page.filterAccessibleGuildIdsnow gates bot-present guilds on owner
status,members.isManager,manager_rolesrole match, or ANY
module_role_grantsrow (configure OR use) that matches a role the
user holds. Bot-absent guilds continue to use the MANAGE_GUILD
filter so invite-flow discovery still works. - Breaking@bolthub/bot·v0.2.0
feat(i18n): add Turkish, Dutch, Danish, Swedish, French, Finnish, Greek locales
- Dashboard and landing now ship
tr,nl,da,sv,fr,fi,elmessage catalogs alongsideenandde; bothlocalesarrays extended and language switchers show the new flags and endonym labels - Guild Settings language picker, landing layout message imports, and the shared
CookieConsentLocaleValueunion all accept the new locales - Bot
t()now resolves the seven new languages for event/RSVP/social-alert/music/access-control keys; English fallback unchanged
Manager Roles + granular per-module role access.
The landing page has been promising Manager Roles + Granular Dashboard Access Control for a while; the
managerRolestable existed butcheckGuildAccess()never consulted it. This changeset wires that promise end-to-end and extends it with per-module role grants that cover both dashboard configuration and Discord-side usage.Permission hierarchy (final):
1. Guild Owner — unconstrained; only owner can edit the Manager Roles list.
2. Global Manager —members.isManager = trueor any Discord role inmanagerRoles. Full dashboard + module access.
3. Module Configure Grant — role inmodule_role_grantswithkind = 'configure'. May edit that module's dashboard settings + manage itsusegrants.
4. Module Use Grant — role inmodule_role_grantswithkind = 'use'. May use the module in Discord (slash commands, buttons, WS controls, public player controls). If a module has zerousegrants, it defaults to open for every guild member.New schema:
module_role_grants(guildId,module,roleId,kind) with unique + composite indexes.New API routes:
GET /guilds/:guildId/access— self-access resolver (returns empty on no-access rather than 403 so dashboard can render a useful state).GET|POST|DELETE /guilds/:guildId/manager-roles(POST/DELETE owner-only).GET|POST|DELETE /guilds/:guildId/modules/:module/grants.
Bot: new Redis channel
bolthub:guilds:get-member-roles(API-side cached 60s).interactionCreaterunscheckModuleUsebefore executing music buttons and RSVP buttons. NewguildMemberUpdatelistener invalidates the member-roles cache key on role diff so a demoted user loses access immediately (60s TTL is only the safety-net).API: new
requireModuleConfigure(module)middleware replacesrequireGuildAccesson feature routes (embeds,events,social-alerts,musicconfig). Music WS handshake now gates onaccess.useModules.has('music').Dashboard: Settings page gains a Manager Roles section (owner-only mutation). Each live-module page gains a Module Access panel (configure / use grants). Sidebar filters nav items for module-configure grantees who aren't blanket managers.
Docs:
docs/schema.md,ARCHITECTURE.md,BUILD_ORDER.md,redis-protocol.mdupdated.i18n: new
managerRoles+accessnamespaces inapps/dashboard/messages/{en,de}.json; newaccess.*keys inapps/bot/src/lib/i18n.ts(en + de).Known limitations (follow-ups): orphaned grants (role deleted in Discord) are not cleaned up; configure-grantees without Discord
MANAGE_GUILDdon't yet appear in the/guildsswitcher.Persist music queue across bot disconnects.
Live queue (current track + upcoming + volume + loop + shuffle) is
mirrored to Redis underbolthub:music:queue:{guildId}with a 7-day
TTL. The queue now survives:- Idle timeout (5min alone in voice) — bot leaves but queue stays
- User presses Stop — bot leaves, queue stays, "Clear Queue" button
wipes it explicitly
- User presses Leave — same as Stop
- Voice-channel switch (Join other VC) — queue carries over
- Bot process restart / crash
- Lavalink node crashCleared only on:
- Natural queue end (
playerEmptywith nothing left)
- User clicks Clear Queue button (already present in MusicQueue UI —
backend now also wipes Redis)
- User triggersqueue:replace-from-playlist(old queue replaced)Implementation:
-
persistQueueState(player)serializes the live state; wired into
KazagumoplayerStart/playerEndevents and every mutating
handler (queue-add / remove / reorder, volume, loop, shuffle).
-restorePersistedQueue(player, requestedBy)runs immediately after
everycreatePlayer()in handlePlay / handleQueueAdd / handleJoin.
Tracks are re-searched through Lavalink by URI (slower than
decoding cached blobs, but robust across Lavalink upgrades and
reuses the existing search-with-fallback pattern).
- Current-track position is NOT preserved; resume starts the track
from 0s. Simpler, 99% UX-OK.
-handleStatefalls back to the Redis snapshot when no live player
exists, so the dashboard keeps showing the persisted queue after
Stop / Leave / idle-timeout / restart — users see what will resume
when they press Play.Redis itself uses RDB snapshots + volume mount, so Redis restarts
don't drop state either. - Dashboard and landing now ship
- Update@bolthub/bot·v0.2.0
Channel-permission awareness across the bot/API/dashboard stack.
The dashboard's ChannelSelect used to show every channel the bot could
_see_, not every channel the bot could _post in_. Admins could pick a
channel where the bot silently failed to send — especially painful on
the onboarding Update-Channel picker where that misconfig wasn't caught
until the first broadcast went out.Fix is full-stack so it can't be bypassed from any surface:
- Bot (
apps/bot/src/modules/guilds/guilds.handler.ts):
bolthub:guilds:get-channelsnow resolves the bot member's real
permission overwrites per channel and returnswritable+speakable
booleans alongside the existing shape.writablerequires
ViewChannel+SendMessages+EmbedLinks;speakablerequires
ViewChannel+Connect+Speak. Voice-type channels report
writable: falseon purpose — BoltHub never posts embeds into a
voice-text buffer.- API (
apps/api/src/lib/channel-permissions.ts): new
assertChannelCapability(guildId, channelId, need)helper. Throws
ConflictError(409) with a user-facing message listing the missing
Discord permissions, orNotFoundErrorif the channel id is unknown
to the bot. Wired into every save-path that persists achannelId:-
PUT /guilds/:guildId/broadcast-subscription
-POST /guilds/:guildId/embeds,PATCH /:embedId,POST /:embedId/send
-POST /guilds/:guildId/events,PATCH /:eventId,
POST /schedules,PATCH /schedules/:scheduleId
-POST /guilds/:guildId/social-alerts,PATCH /:alertIdguilds.controllerGET /channelsinline response type updated to
match the new shape.- Dashboard (
apps/dashboard/src/components/ChannelSelect.tsx,
hooks/use-guild-channels.ts):-
DiscordChannelinterface gainswritable+speakable.
- NewuseRefreshGuildChannels(guildId)hook invalidates the cache so
admins who just granted permissions can pick the channel without a
full page reload.
-ChannelSelectrenders inaccessible channels disabled with a
warning icon, a(Kein Zugriff)badge inline, and an animate-ui
Tooltip ("Bot lacks permission in this channel — grant View
Channel, Send Messages, and Embed Links…") on hover. Radix's
defaultpointer-events: noneon disabled items is overridden with
!pointer-events-autoso hover events reach the Tooltip trigger;
Radix still refuses selection viaaria-disabledinternally so the
gate can't be bypassed through CSS.
-showRefreshprop (defaulttrue) mounts a refresh icon-button
next to the dropdown that spins while fetching.- i18n: three new
commonkeys in bothen.json+de.json—
channelNoAccess(full tooltip text),channelNoAccessShort
("No access" / "Kein Zugriff" inline badge), and
refreshChannelPermissions(refresh button label).- Docs:
docs/redis-protocol.mdbolthub:guilds:get-channels
section updated with the new response shape.docs/brand.md
ChannelSelectsection expanded with the permission-handling rules
and the API-side guard reference.Verified: typecheck + lint green across all 7 packages. Bot + API
restarted undertsx watch. Channel dropdown now disables
non-writable channels with tooltip.Fix: dashboard Play button dead after Stop / Leave.
The persisted-queue work landed but the dashboard had no way to
actually trigger a resume — the center Play button was wired to
onResume(unpause) and disabled wheneverstate.status === "idle",
which is exactly the state Stop + Leave produce.New WS command
resume-queue→ bot channelbolthub:music:resume-queue:- Ensures player (auto-join caller's voice) + restores Redis snapshot
(if not already loaded) + kicks off playback
- Throws ValidationError when nothing is persisted (UI keeps the
button disabled in that case)Center Play button now branches on status:
playing→ Pausepaused→ Resume (unpause existing player)idle+ queue has tracks → Resume Queue (new path)idle+ queue empty → disabled
- Breaking@bolthub/dashboard·v0.2.0
feat(i18n): add Turkish, Dutch, Danish, Swedish, French, Finnish, Greek locales
- Dashboard and landing now ship
tr,nl,da,sv,fr,fi,elmessage catalogs alongsideenandde; bothlocalesarrays extended and language switchers show the new flags and endonym labels - Guild Settings language picker, landing layout message imports, and the shared
CookieConsentLocaleValueunion all accept the new locales - Bot
t()now resolves the seven new languages for event/RSVP/social-alert/music/access-control keys; English fallback unchanged
Manager Roles + granular per-module role access.
The landing page has been promising Manager Roles + Granular Dashboard Access Control for a while; the
managerRolestable existed butcheckGuildAccess()never consulted it. This changeset wires that promise end-to-end and extends it with per-module role grants that cover both dashboard configuration and Discord-side usage.Permission hierarchy (final):
1. Guild Owner — unconstrained; only owner can edit the Manager Roles list.
2. Global Manager —members.isManager = trueor any Discord role inmanagerRoles. Full dashboard + module access.
3. Module Configure Grant — role inmodule_role_grantswithkind = 'configure'. May edit that module's dashboard settings + manage itsusegrants.
4. Module Use Grant — role inmodule_role_grantswithkind = 'use'. May use the module in Discord (slash commands, buttons, WS controls, public player controls). If a module has zerousegrants, it defaults to open for every guild member.New schema:
module_role_grants(guildId,module,roleId,kind) with unique + composite indexes.New API routes:
GET /guilds/:guildId/access— self-access resolver (returns empty on no-access rather than 403 so dashboard can render a useful state).GET|POST|DELETE /guilds/:guildId/manager-roles(POST/DELETE owner-only).GET|POST|DELETE /guilds/:guildId/modules/:module/grants.
Bot: new Redis channel
bolthub:guilds:get-member-roles(API-side cached 60s).interactionCreaterunscheckModuleUsebefore executing music buttons and RSVP buttons. NewguildMemberUpdatelistener invalidates the member-roles cache key on role diff so a demoted user loses access immediately (60s TTL is only the safety-net).API: new
requireModuleConfigure(module)middleware replacesrequireGuildAccesson feature routes (embeds,events,social-alerts,musicconfig). Music WS handshake now gates onaccess.useModules.has('music').Dashboard: Settings page gains a Manager Roles section (owner-only mutation). Each live-module page gains a Module Access panel (configure / use grants). Sidebar filters nav items for module-configure grantees who aren't blanket managers.
Docs:
docs/schema.md,ARCHITECTURE.md,BUILD_ORDER.md,redis-protocol.mdupdated.i18n: new
managerRoles+accessnamespaces inapps/dashboard/messages/{en,de}.json; newaccess.*keys inapps/bot/src/lib/i18n.ts(en + de).Known limitations (follow-ups): orphaned grants (role deleted in Discord) are not cleaned up; configure-grantees without Discord
MANAGE_GUILDdon't yet appear in the/guildsswitcher. - Dashboard and landing now ship
- Update@bolthub/dashboard·v0.2.0
Move Module Access Panel into module settings route.
Access configuration now lives at
/guilds/:guildId/modules/:module/settingsalongside the module's
other settings, instead of being a card on the module's main list
page. Keeps access + module config in one place and declutters the
embeds + music landing pages.Channel-permission awareness across the bot/API/dashboard stack.
The dashboard's ChannelSelect used to show every channel the bot could
_see_, not every channel the bot could _post in_. Admins could pick a
channel where the bot silently failed to send — especially painful on
the onboarding Update-Channel picker where that misconfig wasn't caught
until the first broadcast went out.Fix is full-stack so it can't be bypassed from any surface:
- Bot (
apps/bot/src/modules/guilds/guilds.handler.ts):
bolthub:guilds:get-channelsnow resolves the bot member's real
permission overwrites per channel and returnswritable+speakable
booleans alongside the existing shape.writablerequires
ViewChannel+SendMessages+EmbedLinks;speakablerequires
ViewChannel+Connect+Speak. Voice-type channels report
writable: falseon purpose — BoltHub never posts embeds into a
voice-text buffer.- API (
apps/api/src/lib/channel-permissions.ts): new
assertChannelCapability(guildId, channelId, need)helper. Throws
ConflictError(409) with a user-facing message listing the missing
Discord permissions, orNotFoundErrorif the channel id is unknown
to the bot. Wired into every save-path that persists achannelId:-
PUT /guilds/:guildId/broadcast-subscription
-POST /guilds/:guildId/embeds,PATCH /:embedId,POST /:embedId/send
-POST /guilds/:guildId/events,PATCH /:eventId,
POST /schedules,PATCH /schedules/:scheduleId
-POST /guilds/:guildId/social-alerts,PATCH /:alertIdguilds.controllerGET /channelsinline response type updated to
match the new shape.- Dashboard (
apps/dashboard/src/components/ChannelSelect.tsx,
hooks/use-guild-channels.ts):-
DiscordChannelinterface gainswritable+speakable.
- NewuseRefreshGuildChannels(guildId)hook invalidates the cache so
admins who just granted permissions can pick the channel without a
full page reload.
-ChannelSelectrenders inaccessible channels disabled with a
warning icon, a(Kein Zugriff)badge inline, and an animate-ui
Tooltip ("Bot lacks permission in this channel — grant View
Channel, Send Messages, and Embed Links…") on hover. Radix's
defaultpointer-events: noneon disabled items is overridden with
!pointer-events-autoso hover events reach the Tooltip trigger;
Radix still refuses selection viaaria-disabledinternally so the
gate can't be bypassed through CSS.
-showRefreshprop (defaulttrue) mounts a refresh icon-button
next to the dropdown that spins while fetching.- i18n: three new
commonkeys in bothen.json+de.json—
channelNoAccess(full tooltip text),channelNoAccessShort
("No access" / "Kein Zugriff" inline badge), and
refreshChannelPermissions(refresh button label).- Docs:
docs/redis-protocol.mdbolthub:guilds:get-channels
section updated with the new response shape.docs/brand.md
ChannelSelectsection expanded with the permission-handling rules
and the API-side guard reference.Verified: typecheck + lint green across all 7 packages. Bot + API
restarted undertsx watch. Channel dropdown now disables
non-writable channels with tooltip.Fix configure-grantees editing module grants + self-role preservation.
module_role_grantsroutes were guarded byrequireGuildAccess
(blanket managers only). Configure-grantees therefore couldn't even
GET the grant list for their module — the Use Roles dropdown on the
Access Panel rendered but stayed empty, and the Add-button 403'd.Routes now use
requireAnyGuildAccess; service-level
assertMayMutatealready scoped mutations correctly (configure-only
for blanket, use-only for configure-grantees).Added
assertNotSelfRole: non-blanket callers cannot add or remove
grants for one of their own Discord roles. Without this a configure-
grantee could revoke the very role that grants them access and lock
out the whole role. Owners + global-managers bypass./guilds/:guildId/accessnow returns the caller'sroleIdsso the
dashboard hides the remove-button + disables the add-button for
self-role grants. Self-role grants show a "Your role" badge so it's
obvious why they're read-only.Fix: dashboard Play button dead after Stop / Leave.
The persisted-queue work landed but the dashboard had no way to
actually trigger a resume — the center Play button was wired to
onResume(unpause) and disabled wheneverstate.status === "idle",
which is exactly the state Stop + Leave produce.New WS command
resume-queue→ bot channelbolthub:music:resume-queue:- Ensures player (auto-join caller's voice) + restores Redis snapshot
(if not already loaded) + kicks off playback
- Throws ValidationError when nothing is persisted (UI keeps the
button disabled in that case)Center Play button now branches on status:
playing→ Pausepaused→ Resume (unpause existing player)idle+ queue has tracks → Resume Queue (new path)idle+ queue empty → disabled
Page-width wrapper + HTTP cache hardening.
Dashboard: new
<Page width="narrow|default|wide">component applied
to every/guilds/:guildId/**route. Lists + overview get
max-w-6xl, settings forms getmax-w-3xl, music player + embed
editor getmax-w-none. Player (/player/**) and Admin routes are
intentionally untouched. Picked the component-wrapper approach over
Next route-groups because/embedslist and/embeds/[id]editor
need different widths under the same segment — route-groups don't
support segment-sharing across groups, a wrapper keeps the
decision co-located with the page.API: every response now gets
Cache-Control: private, no-store
unless the handler already set one. Fixes a list-stale-after-mutate
bug where the client invalidated["embeds", guildId]correctly,
but the refetch was served from browser heuristic cache instead of
hitting the API. React Query already owns client-side freshness —
HTTP cache on top was working against it.fix(sidebar): drive active/hover indicator with motion layoutId
Replaces the ref-based
getBoundingClientRect+useEffectmeasurement
inSidebarwith motion/react'slayoutIdFLIP. The indicator pill and
orange active bracket now live inside the active<li>itself, so they
reposition automatically whenever nav items resize — including when
labels wrap to two lines on locale switch, when fonts finish loading, or
when the Modules-enabled dot appears/disappears. Previously the rect was
only recomputed onactiveKey/hoveredKey/pathnamechanges, so a
locale swap left the indicator stranded at the old coordinates. - Update@bolthub/dashboard·v0.1.11
Gate events + social_alerts as "coming soon" until finalized.
-
@bolthub/types: newLIVE_MODULESset +isModuleLive()helper —
single source of truth for which modules can be enabled. Currently
embeds+musiconly.
-@bolthub/api:modules.service.updateModulethrowsForbiddenError
when enabling a non-live module, even if the plan would allow it.
-@bolthub/dashboard: Sidebar + modules page consumeisModuleLive
instead of a local hardcoded set.ModuleGateshows a dedicated
"Coming Soon" empty state (no Activate button) for non-live modules.
New i18n keysmodules.comingSoonTitle+comingSoonDescription
(en + de).
-@bolthub/landing: Features section marksevents+socialAlerts
ascomingSoon; live modules (embeds,music) lead the grid. - Update@bolthub/api·v0.1.9
Gate events + social_alerts as "coming soon" until finalized.
-
@bolthub/types: newLIVE_MODULESset +isModuleLive()helper —
single source of truth for which modules can be enabled. Currently
embeds+musiconly.
-@bolthub/api:modules.service.updateModulethrowsForbiddenError
when enabling a non-live module, even if the plan would allow it.
-@bolthub/dashboard: Sidebar + modules page consumeisModuleLive
instead of a local hardcoded set.ModuleGateshows a dedicated
"Coming Soon" empty state (no Activate button) for non-live modules.
New i18n keysmodules.comingSoonTitle+comingSoonDescription
(en + de).
-@bolthub/landing: Features section marksevents+socialAlerts
ascomingSoon; live modules (embeds,music) lead the grid.