Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`automod` categories
- Command directory loading now ignores `*.spec.*` and `*.test.*` modules so
test files are never registered as slash commands
- Dashboard guild authorization now tolerates per-guild context failures
instead of dropping the full `/api/guilds` response when one guild fails
- Discord guild permission parsing now supports payload drift
(`permissions`/`permissions_new`) and safely handles invalid permission values
- `/api/guilds` now maps recoverable Discord OAuth/scope/session failures to
actionable auth responses (401/403) and maps upstream Discord outages to 502
- `GET /api/guilds/:id/me` no longer requires `overview` module access so the
dashboard can always bootstrap member context for authorized users
- Server selector now distinguishes true empty authorization from
fetch/auth/session failures, showing retry and re-auth actions for failure
states instead of a misleading empty result
- Features route guard mapping is now consistent under the `automation` module
across frontend route guards, sidebar module checks, and backend route guards
- `/servers` is now always accessible for authenticated users (not blocked by
module RBAC guards), while server/module pages remain module-gated
- Guild auto-selection now picks the first server where Lucky is already added;
when no server has Lucky installed, dashboard keeps no selected server and
shows explicit selection guidance
- Refs: PR `#169`

### Changed

Expand Down
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,11 @@ packages/
- Neo-editorial shell with responsive sidebar and contextual page framing
- Dashboard, Servers, and Last.fm pages aligned to shared status/empty-state primitives
- Module/command toggle per server
- Guild RBAC by Discord role (`view`/`manage`) with deny-by-default for
non-admin users
- Guild RBAC by Discord role (`view`/`manage`) with hybrid fallback:
owners/admin/manage-server users keep baseline access when grants are absent,
while role grants control non-admin module access
- `/servers` remains available to authenticated users even when module-level
access is restricted, so server discovery/invite flows stay reachable
- Sidebar identity resolution chain: `nick > globalName > username`
- Dashboard guild metrics now use live bot/API counts, rendering unknown values
as `—` instead of `0`
Expand Down Expand Up @@ -181,6 +184,16 @@ deployments should keep `VITE_API_BASE_URL` aligned with the public backend.
Authenticated frontend shell routes now bootstrap guild selection globally, so
the server selector is populated immediately after login without requiring a
visit to `/servers` first.
Guild auto-selection prioritizes the first server where Lucky is already added;
if none are bot-added, the dashboard keeps no selected server and shows a clear
selection/empty guidance state.
Server selector empty/error states are split:
- `No accessible servers found` means authentication worked but no authorized
guilds matched your access policy.
- `Could not load servers` means auth/session/network/upstream fetch failed;
use `Retry` or `Re-authenticate` from the selector.
- `Select a Server` in dashboard after login means no bot-added server was
auto-selected yet; open `/servers` to invite Lucky to additional servers.
Without `VITE_API_BASE_URL`, frontend uses same-origin `/api` for
`*.lucassantana.tech` hosts and `api.luk-homeserver.com.br` for
`*.luk-homeserver.com.br`.
Expand Down
69 changes: 64 additions & 5 deletions packages/backend/src/routes/guilds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,70 @@ async function getSessionData(req: AuthenticatedRequest) {
return sessionData
}

function getStatusCode(error: unknown): number | null {
if (typeof error !== 'object' || error === null) {
return null
}

const errorObject = error as { statusCode?: unknown; status?: unknown }
if (typeof errorObject.statusCode === 'number') {
return errorObject.statusCode
}

if (typeof errorObject.status === 'number') {
return errorObject.status
}

return null
}

function mapGuildAccessError(error: unknown): Error {
if (error instanceof AppError) {
return error
}

const statusCode = getStatusCode(error)

if (statusCode === 401) {
return AppError.unauthorized(
'Discord session expired. Please sign in again.',
)
}

if (statusCode === 403) {
return AppError.forbidden(
'Discord OAuth scope is missing. Re-authenticate and try again.',
)
}

if (statusCode === 429 || (statusCode !== null && statusCode >= 500)) {
return new AppError(502, 'Discord API is temporarily unavailable.')
}

return error instanceof Error
? error
: new Error('Internal server error')
}

async function runGuildAccessOperation<T>(
operation: () => Promise<T>,
): Promise<T> {
try {
return await operation()
} catch (error) {
throw mapGuildAccessError(error)
}
}

export function setupGuildRoutes(app: Express): void {
app.get(
'/api/guilds',
requireAuth,
asyncHandler(async (req: AuthenticatedRequest, res: Response) => {
const sessionData = await getSessionData(req)
const guilds =
await guildAccessService.listAuthorizedGuilds(sessionData)
const guilds = await runGuildAccessOperation(() =>
guildAccessService.listAuthorizedGuilds(sessionData),
)

res.json({ guilds })
}),
Expand All @@ -46,8 +102,9 @@ export function setupGuildRoutes(app: Express): void {
const id = getGuildId(req)
const sessionData = await getSessionData(req)

const guilds =
await guildAccessService.listAuthorizedGuilds(sessionData)
const guilds = await runGuildAccessOperation(() =>
guildAccessService.listAuthorizedGuilds(sessionData),
)
const guildDetails = guilds.find((guild) => guild.id === id)

if (!guildDetails) {
Expand Down Expand Up @@ -79,7 +136,9 @@ export function setupGuildRoutes(app: Express): void {

const guildContext =
req.guildContext ??
(await guildAccessService.resolveGuildContext(sessionData, id))
(await runGuildAccessOperation(() =>
guildAccessService.resolveGuildContext(sessionData, id),
))
if (!guildContext) {
throw AppError.forbidden('No access to this server')
}
Expand Down
120 changes: 112 additions & 8 deletions packages/backend/src/services/DiscordOAuthService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,21 @@ export interface DiscordGuild {
icon: string | null
owner: boolean
permissions: string
permissions_new?: string
features: string[]
}

export class DiscordApiError extends Error {
constructor(
message: string,
public readonly statusCode: number,
public readonly endpoint: string,
) {
super(message)
this.name = 'DiscordApiError'
}
}

interface TokenResponse {
access_token: string
token_type: string
Expand All @@ -30,6 +42,80 @@ interface TokenResponse {
class DiscordOAuthService {
private readonly apiBaseUrl = 'https://discord.com/api/v10'

private normalizePermissionValue(value: unknown): string | null {
if (typeof value === 'string') {
const normalized = value.trim()
return normalized.length > 0 ? normalized : null
}

if (
typeof value === 'number' &&
Number.isFinite(value) &&
value >= 0 &&
Number.isInteger(value)
) {
return String(value)
}

return null
}

private parsePermissionBits(
permissions: string | null | undefined,
): bigint | null {
const normalized = this.normalizePermissionValue(permissions)
if (!normalized) {
return null
}

try {
return BigInt(normalized)
} catch {
return null
}
}

private normalizeGuildPayload(payload: unknown): DiscordGuild[] {
if (!Array.isArray(payload)) {
return []
}

const guilds: DiscordGuild[] = []

for (const rawGuild of payload) {
if (typeof rawGuild !== 'object' || rawGuild === null) {
continue
}

const guild = rawGuild as Record<string, unknown>
if (typeof guild.id !== 'string' || typeof guild.name !== 'string') {
continue
}

const permissions = this.normalizePermissionValue(guild.permissions)
const permissionsNew = this.normalizePermissionValue(
guild.permissions_new,
)

guilds.push({
id: guild.id,
name: guild.name,
icon: typeof guild.icon === 'string' ? guild.icon : null,
owner: guild.owner === true,
permissions: permissions ?? permissionsNew ?? '0',
permissions_new: permissionsNew ?? undefined,
features: Array.isArray(guild.features)
? guild.features.filter(
(feature): feature is string =>
typeof feature === 'string',
)
: [],
})
}

return guilds
}

private getClientId(): string {
const clientId = process.env.CLIENT_ID
if (!clientId) {
Expand Down Expand Up @@ -74,8 +160,10 @@ class DiscordOAuthService {

if (!response.ok) {
const errorText = await response.text()
throw new Error(
throw new DiscordApiError(
`Token exchange failed: ${response.status} ${errorText}`,
response.status,
'/oauth2/token',
)
}

Expand All @@ -98,8 +186,10 @@ class DiscordOAuthService {

if (!response.ok) {
const errorText = await response.text()
throw new Error(
throw new DiscordApiError(
`Failed to fetch user info: ${response.status} ${errorText}`,
response.status,
'/users/@me',
)
}

Expand Down Expand Up @@ -128,12 +218,14 @@ class DiscordOAuthService {

if (!response.ok) {
const errorText = await response.text()
throw new Error(
throw new DiscordApiError(
`Failed to fetch user guilds: ${response.status} ${errorText}`,
response.status,
'/users/@me/guilds',
)
}

const guilds = (await response.json()) as DiscordGuild[]
const guilds = this.normalizeGuildPayload(await response.json())
debugLog({
message: 'Successfully fetched user guilds',
data: { count: guilds.length },
Expand All @@ -145,8 +237,18 @@ class DiscordOAuthService {
}
}

hasAdminPermission(permissions: string): boolean {
const permissionsBigInt = BigInt(permissions)
hasAdminPermission(
permissions: string | null | undefined,
permissionsNew?: string | null,
): boolean {
const permissionsBigInt =
this.parsePermissionBits(permissionsNew) ??
this.parsePermissionBits(permissions)

if (permissionsBigInt === null) {
return false
}

const administratorPermission = BigInt(0x8)
const manageGuildPermission = BigInt(0x20)

Expand All @@ -160,7 +262,7 @@ class DiscordOAuthService {

filterAdminGuilds(guilds: DiscordGuild[]): DiscordGuild[] {
return guilds.filter((guild) =>
this.hasAdminPermission(guild.permissions),
this.hasAdminPermission(guild.permissions, guild.permissions_new),
)
}

Expand All @@ -181,8 +283,10 @@ class DiscordOAuthService {

if (!response.ok) {
const errorText = await response.text()
throw new Error(
throw new DiscordApiError(
`Token refresh failed: ${response.status} ${errorText}`,
response.status,
'/oauth2/token',
)
}

Expand Down
Loading
Loading