Files
stoat-mbot/src/auth/tokens.ts
T
Leonid PershinandClaude Opus 5 a9b7ccdd16 Add music bot for self-hosted Stoat with web control panel
Plays audio into Stoat voice channels over LiveKit and exposes the same
player through both chat commands and a browser panel, so the two never
drift apart: everything routes through a single MusicManager.

- core: per-server GuildPlayer (queue, loop, shuffle, seek, volume,
  idle auto-leave) driving revoice.js/@livekit/rtc-node and ffmpeg
- sources: yt-dlp for YouTube/SoundCloud, direct media URLs and internet
  radio, optional local library with path-traversal guards
- bot: 18 chat commands with aliases, plus !panel one-time login links
- api: Fastify REST + WebSocket, sessions authenticated against the
  instance's own /auth/session/login (TOTP supported), permissions
  re-checked against Stoat membership and roles on every request
- web: React panel with search, queue editing, seek and volume
- deploy: Dockerfile, compose.override.yml and Caddyfile snippets for
  dropping the service into an existing /opt/stoat stack

Verified with npm run typecheck, both builds, and scripts/smoke-api.mjs
(9 API checks). Voice playback itself needs a live instance to test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:12:43 +03:00

54 lines
1.6 KiB
TypeScript

import { SignJWT, jwtVerify } from "jose";
import { config } from "../config.js";
const secret = new TextEncoder().encode(config.JWT_SECRET);
const ISSUER = "stoat-mbot";
export interface SessionClaims {
sub: string;
username: string;
/** "session" for the panel cookie, "link" for one-time links from chat. */
typ: "session" | "link";
/** Present on chat links: the server the link was issued for. */
srv?: string;
}
export async function signSession(userId: string, username: string): Promise<string> {
return new SignJWT({ username, typ: "session" })
.setProtectedHeader({ alg: "HS256" })
.setSubject(userId)
.setIssuer(ISSUER)
.setIssuedAt()
.setExpirationTime(`${config.SESSION_TTL_HOURS}h`)
.sign(secret);
}
export async function signPanelLink(
userId: string,
username: string,
serverId: string,
): Promise<string> {
return new SignJWT({ username, typ: "link", srv: serverId })
.setProtectedHeader({ alg: "HS256" })
.setSubject(userId)
.setIssuer(ISSUER)
.setIssuedAt()
.setExpirationTime("10m")
.sign(secret);
}
export async function verifyToken(token: string): Promise<SessionClaims | null> {
try {
const { payload } = await jwtVerify(token, secret, { issuer: ISSUER });
if (!payload.sub || (payload["typ"] !== "session" && payload["typ"] !== "link")) return null;
return {
sub: payload.sub,
username: String(payload["username"] ?? "unknown"),
typ: payload["typ"] as "session" | "link",
srv: typeof payload["srv"] === "string" ? payload["srv"] : undefined,
};
} catch {
return null;
}
}