Brainstorm-approved design for an optional, default-off guest mode: - guest = anonymous, config-driven principal (no account) - per-ability admin toggles (add-to-end default on; play-next/play-now/ skip/transport/remove-clear/play-mode opt-in) + per-bot guest scope - unified authorize() gate; settings always locked for guests; non-destructive guest "play now" Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
18 KiB
Guest mode (login-less WebUI access) — design
Issue: #83 — "请求增加 WebUI 鉴权 guest 登录功能" Date: 2026-06-24 Status: Approved (brainstorm), pending implementation plan
Scope
Add an optional, default-OFF guest mode. When an admin enables it, anyone who can reach the WebUI can enter without logging in ("以游客身份进入 / Continue as guest") and use a restricted subset of playback/queue features. The admin chooses, per deployment, exactly what guests may do (a set of toggles) and which bot(s) guests may control. Guests can never view or change settings, manage bots, set platform credentials, change audio quality, or see the user/audit admin panels.
This builds directly on the existing admin | member role + capability system
(src/data/permissions.ts, requirePermission, useSession().can()) and the existing
append-vs-play-next queue split (PlayQueue.add vs addNext). It does not rebuild
auth.
The original issue asked specifically that guest song requests go to "下一首" only. That exact behavior is reproducible in this design by the admin turning the "add to end" toggle off and the "play next" toggle on — it is one configuration of a more general per-ability toggle model (chosen in brainstorm).
Problem
Today every /api/* route past the session router requires a real account
(requireAuth). There is no anonymous/guest path: to let a friend queue a song, an
admin must create them a member account. The maintainer wants a low-friction,
admin-gated way to let untrusted visitors request music without an account, while
keeping all administration locked down.
Decisions (from brainstorm)
- Guest = no-login. A guest is an anonymous, config-driven synthetic principal
(
role: "guest"), not a database user with a password. No favorites, no change-password, short-lived session. - Default OFF, enforced server-side (the guest-session endpoint rejects when the flag is off — never rely on hiding the button).
- Per-ability toggles, not a single "mode". Every song action and control action is its own admin switch. Default state when guest mode is first enabled: only "add to end of queue" is ON; everything else OFF.
- Per-bot guest scope (
"all"or an explicit bot list), mirroring the existing member bot allow-list. Guests cannot see or control out-of-scope bots — including over WebSocket. - Settings are always hidden AND server-blocked for guests (view + change), closing
the two currently-ungated reads (
GET /api/bot/settings,GET /api/music/quality). - "Play now" for guests is non-destructive: insert-next + skip to it, never the
existing clear-the-whole-queue
/play-songbehavior. - One unified authorization gate encapsulates admin/member/guest logic so the existing member/admin capability system is left behavior-unchanged.
Guest ability model
Always allowed (baseline read-only — the point of the feature)
- Browse/search library, playlists, history, song detail, lyrics, cover art.
- See now-playing and the live queue (REST + WebSocket), scoped to allowed bots.
Always denied (hard locks — not toggles)
- View or change any settings (idle timeout, auto-pause, theme persistence server-side, command prefix, etc.).
- Bot management (create/edit/delete/start/stop, bot config, avatar, profile).
- Music-platform login (
/api/auth/*), audio quality (/api/music/quality). - User management (
/api/users), audit log (/api/audit), change-password.
Admin-configurable toggles (guestMode.permissions.*, all default false except addToQueue)
| Flag | 中文 | Default | Backend route(s) gated |
|---|---|---|---|
addToQueue |
添加到队列末尾 | true | POST /:botId/add, /add-song, /add-by-id |
playNext |
添加到下一首 | false | POST /:botId/play-next-song |
playNow |
立即播放(不清空队列) | false | new guest-safe play-now (insert-next + skip) |
skip |
跳过当前歌曲 | false | POST /:botId/next |
transport |
暂停/继续/进度/音量 | false | POST /:botId/pause, /resume, /seek, /volume |
removeClear |
移除/清空队列 | false | DELETE /:botId/queue/:index, POST /:botId/clear |
playMode |
切换播放模式 / FM | false | POST /:botId/mode, /fm |
Notes:
- Routes with no guest flag (e.g.
/prev,/stop,/play-song,/play-playlist,/play-album,/play-at, all of/api/bot/*,/api/auth/*, settings, users, audit) are never reachable by guests — the gate denies any guest without an explicit flag. This is the safe default: new routes are guest-denied unless deliberately opted in. /play-song,/play-playlist,/play-albumcallqueue.clear()and must stay guest-denied regardless of toggles (they would wipe everyone's queue).
Config schema (src/data/config.ts)
export interface GuestPermissions {
addToQueue: boolean; // append to end
playNext: boolean; // 下一首 (insert after current)
playNow: boolean; // 立即播放: insert-next + skip-to-it (non-destructive)
skip: boolean; // skip current track
transport: boolean; // pause/resume/seek/volume
removeClear: boolean; // remove a queue item / clear the queue
playMode: boolean; // play mode (shuffle/repeat) + FM
}
export interface GuestModeConfig {
enabled: boolean; // master switch, default false
bots: "all" | string[]; // per-bot scope (botIds); default "all"
permissions: GuestPermissions;
}
// added to BotConfig:
guestMode: GuestModeConfig;
getDefaultConfig() returns:
guestMode: {
enabled: false,
bots: "all",
permissions: {
addToQueue: true, playNext: false, playNow: false,
skip: false, transport: false, removeClear: false, playMode: false,
},
}
Merge hardening: loadConfig currently does a shallow {...defaults, ...partial},
which would drop guestMode sub-keys if a saved config only contains a partial
guestMode. loadConfig must deep-merge guestMode (and its permissions) over
the defaults so missing sub-keys are back-filled. Covered by a config.test.ts case.
Bot deletion: when a bot is removed, prune its id from guestMode.bots (if it's an
array) and persist — mirrors PermissionStore.pruneBot(botId) for members. Done in the
same BotManager.removeBot path that already prunes member access.
Backend design
Synthetic guest principal & session entry
- Role union widened to
"admin" | "member" | "guest"(UserRoleinsrc/data/users.ts, thereq.useraugmentation inrequireAuth.ts, the frontendUsertype, and the role badge in Navbar). - Reserved guest user row. A single fixed row (e.g. id
"__guest__", role"guest", an unusable password hash, username e.g."guest") is created idempotently by migration. It exists only to satisfy thesessions.userIdFK and thevalidateAndTouchJOIN; it is excluded from user-management listings and the last-admin guards (those countrole = 'admin'only, so guests don't interfere). - Guest login endpoint:
POST /api/session/guest, mounted in the public block (beforecsrfOriginCheck/requireAuth, like/loginand/setup), rate-limited.- If
config.guestMode.enabledis false →403 guest mode disabled. - Else
sessions.createSession("__guest__")and set the sametsmb_sessionhttpOnly cookie. Guest sessions use a short TTL (e.g.GUEST_SESSION_TTL_MS, ~24h) and bypassMAX_SESSIONS_PER_USERfor the guest principal (otherwise guest #11 would evict guest #1). Expired guest sessions are already deleted on validation; an optional periodic sweep can prune stale ones.
- If
- Disable = logout. In
createRequireAuth/validateSession, if a validated session hasrole === "guest"butconfig.guestMode.enabledis now false, treat it as unauthenticated (401). So flipping guest mode off immediately ends guest access. - Expose availability: extend
GET /api/session/needs-setup(or add a siblingGET /api/session/guest-config) to returnguestAllowed: booleanso the public Login page can decide whether to show the guest button. This must not leak any other config.
Permission resolution
resolvePermissionContext gains a guest branch. Signature extended to receive the
live guest config:
resolvePermissionContext(role, userId, store, guestConfig?) => {
admin → { capabilities: all CAPABILITIES, bots: "all" }
member → stored caps + stored bots // unchanged
guest → {
capabilities: new Set(), // holds NO member capabilities
bots: guestConfig.bots === "all" ? "all" : new Set(guestConfig.bots),
guest: guestConfig.permissions, // resolved per-request from live config
}
}
PermissionContext and req.user gain an optional guest?: GuestPermissions. Because
req.user is rebuilt per request, toggling a permission or the bot scope takes effect on
the guest's next request (no re-login).
Unified authorization gate (src/web/middleware/authorize.ts, new)
Replaces requirePermission('x') on guest-reachable routes:
authorize({ capability?: Capability, guestFlag?: keyof GuestPermissions })
// 401 if no req.user
// admin → next()
// guest → (req.user.guest?.[guestFlag] === true) ? next() : 403 // also 403 if no guestFlag
// member → (capability && req.user.capabilities.has(capability)) ? next() : 403
- Member/admin semantics are identical to today's
requirePermission. - A route with no
guestFlagis automatically guest-denied (safe default). requireBotAccessis unchanged and already enforces the guestbotsscope (guests flow throughreq.user.bots).requireAdminis unchanged (guests are non-admin → 403), so/api/usersand/api/auditstay locked.
Route changes (src/web/api/player.ts, bot.ts, music.ts)
- Re-express guest-reachable player routes via
authorize({ capability, guestFlag }):/add,/add-song,/add-by-id→{ capability: "player.queue", guestFlag: "addToQueue" }/play-next-song→{ capability: "player.control", guestFlag: "playNext" }(members keepplayer.control; guests pass only viaplayNext)- new guest-safe play-now →
{ capability: "player.control", guestFlag: "playNow" } /next→{ capability: "player.control", guestFlag: "skip" }/pause,/resume,/seek,/volume→{ capability: "player.control", guestFlag: "transport" }DELETE /queue/:index,/clear→{ capability: "player.queue", guestFlag: "removeClear" }/mode,/fm→{ capability: "player.control", guestFlag: "playMode" }- everything else stays
authorize({ capability })(no guest flag) → guest-denied.
- Guest-safe play-now: a new behavior (own route, e.g.
POST /:botId/play-now, or amode:"now"branch) that doesqueue.addNext(song)then advances to it (skip into the inserted track) — noqueue.clear(). Members/admins may also use it; the existing destructive/play-songstays for the normal ▶ in non-guest UI. Exact wiring decided in the plan. - Close ungated reads against guests:
GET /api/bot/settingsandGET /api/music/qualitycurrently have no guard, so a guest could read config. Add a smallrequireNotGuestguard (allowadmin+member, denyguest→ 403). This does not change member/admin behavior — members keep their current read access; only guests are newly denied. (Deliberately not a new member capability, to avoid touching member semantics.)
WebSocket (src/web/websocket.ts, src/web/server.ts)
- Guests authenticate over the WS upgrade unchanged (session cookie).
- Add per-client bot-scope filtering for guests: the upgrade handler already stamps
ws.userId; also resolve and stamp the client's bot scope ("all"or a Set). InsetupWebSocket, when sendinginitand broadcastingstateChange/botConnected/Disconnected/Removed, filter to bots the client may see. For guests with a scopedbotslist, out-of-scope bots are omitted. Admin/member payloads are unchanged (they resolve to"all"or their existing member scope — to avoid changing member behavior, filtering may be applied only when the client is a guest; decided in the plan).
Settings write (POST /api/bot/settings)
Extend the existing settings writer (today only idle-timeout + auto-pause) to also accept
and persist the guestMode block (admin-only via bot.manage/requireAdmin), calling
saveConfig. Live effect: subsequent guest requests read the updated in-memory config.
Frontend design
useSession.ts: extendUserwithrole:'guest'and aguest?: GuestPermissionsfield (from/api/session/me). AddisGuestcomputed andguestCan(flag); makecanguest-aware where it maps cleanly, but UI gating for guest-specific actions usesguestCan('addToQueue' | 'playNext' | ...).canControlBotalready enforces the bot scope and works for guests via thebotsfield.- Login page (
Login.vue): whenguestAllowed, show a prominent "以游客身份进入 / Continue as guest" button calling a newsession.continueAsGuest()→POST /api/session/guest→ refresh → redirect to?nextor home. - Router (
web/src/router/index.ts): in the globalbeforeEach, block guests from/settingsand/setup(redirect to home). Default-off ⇒ when not a guest, behavior is unchanged. - Navbar (
Navbar.vue): hide the settings cog for guests; add a游客role badge branch; the bot selector already filters viacanControlBot, so scoped guests only see allowed bots. - App shell (
App.vue): hide the mobile/settingstab for guests; the mini-player transport reduces to the guest's allowed actions. - SongCard / Queue / Player: gate each action button by the matching
guestCan(flag)(e.g. show ▶/下一首/添加 perplayNow/playNext/addToQueue; show skip/transport/ remove/clear/mode per their flags). Buttons a guest lacks are hidden, mirroring howQueue.vuealready gates oncan('player.queue')/can('player.control'). - Settings → Guest mode admin section (
Settings.vue): new admin-only panel: a master enable switch, the 7 permission checkboxes (with 中文 labels), and a bot scope control (an "全部机器人 / all bots" toggle + per-bot checkboxes) reusing the existing member permission-editor bot allow-list UI. Saving callsPOST /api/bot/settingswith theguestModeblock.
Defaults, migration & backward-compat
getDefaultConfig().guestMode.enabled = false⇒ no behavior change on upgrade; existing installs see nothing until an admin opts in.- Migration adds the reserved
__guest__user row idempotently (guarded like the existingbackfillMemberPermissionsschema_metamarker) and does not grant it anyuser_permissions(guest authorization is config-driven, not row-driven). loadConfigdeep-mergesguestModeso older config files gain the new block with defaults.- Member/admin flows, capabilities, and the backfill are untouched.
Testing (TDD)
- Config:
getDefaultConfigincludesguestModedefault-off;loadConfigdeep-merges a partialguestMode(missing sub-keys back-filled); round-trips throughsaveConfig. resolvePermissionContextguest branch: empty member capabilities;bots"all"vs scoped Set;guestpermissions object passthrough.authorizegate: admin bypass; member has/lacks capability → 200/403 (regression parity withrequirePermission); guest allowed only when the specific flag is true; guest with no flag on a route → 403; guest on settings reads → 403.- Enforcement (mirror
permissions-enforcement.test.ts): each toggle independently opens exactly its route(s) for a guest and nothing else;/play-song//play-playlist//play-albumalways 403 for guests; per-bot scope: guest 403 on out-of-scope:botId. - Session entry:
POST /api/session/guest→ 403 when disabled, mints guest session when enabled; guest session bypassesMAX_SESSIONS_PER_USER; disabling guest mode invalidates existing guest sessions (401); guest TTL shorter than member TTL. - WS scope: guest receives only in-scope bots'
init/stateChange; reject upgrade unchanged for no cookie. - Frontend (where covered):
guestCangating; router blocks/settingsfor guests.
Non-goals (YAGNI)
- No guest accounts/usernames, passwords, favorites, or persistence per guest.
- No per-guest individual identity or rate-limiting beyond the existing IP rate limits
(a basic abuse guard on
/api/session/guestis in; richer abuse controls are future). - No change to the
admin | membercapability semantics; guest is an additive, config-driven third principal. - No chat-command (TeamSpeak
!add/!playnext) changes — guest mode is WebUI-only (the issue is explicitly about WebUI 鉴权). - Per-guest bot scoping beyond a single shared guest scope is out of scope (one guest scope applies to all guests).
Key files touched
Backend: src/data/config.ts (+test), src/data/permissions.ts (+test),
src/data/users.ts (role union, reserved guest row), src/data/database.ts (migration),
src/data/sessions.ts (guest TTL + cap bypass), src/web/middleware/authorize.ts (new,
+test), src/web/middleware/requireNotGuest.ts (new, small — for the config reads),
src/web/api/session.ts (guest endpoint, /me, needs-setup),
src/web/api/player.ts (re-gate + guest play-now), src/web/api/bot.ts (settings
read-lock + guestMode write), src/web/api/music.ts (quality read-lock),
src/web/server.ts + src/web/websocket.ts (WS scope), src/web/auth/validateSession.ts
(guest disable→401), enforcement tests.
Frontend: web/src/composables/useSession.ts, web/src/views/Login.vue,
web/src/router/index.ts, web/src/components/Navbar.vue, web/src/App.vue,
web/src/components/SongCard.vue, web/src/components/Queue.vue,
web/src/components/Player.vue, web/src/views/Settings.vue,
web/src/stores/player.ts.