Skip to content
Merged
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Deploy workflow auth smoke gate now strictly requires
`/api/health/auth-config` with `status=ok`, no warnings, and healthy
auth-session/Redis flags (no fallback to generic health endpoint)
- Backend route handlers now use schema-typed request parsing and explicit auth
user-id guards (removed unsafe `any` request/body/query reads and non-null
assertions across management, moderation, music, toggles, and twitch routes)
- Session middleware now uses typed `session-file-store` import wiring and
strict `connect-redis` adapter wiring without unsafe casts

### Added

- New auth readiness endpoint: `GET /api/health/auth-config` returning
`status`, auth/runtime flags, and deploy-safe warnings for OAuth/session
validation

### Changed

- Backend lint no longer uses scoped ignore guardrails; strict lint now runs
across the full backend package by default (issue #136 closure)

## [2.6.6] - 2026-03-10

### Added
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,9 @@ npm run test:coverage # With coverage report
npm run format # Prettier
```

Backend lint is currently scoped to active quality-gate paths while legacy strict
rule debt is tracked in [issue #136](https://github.com/LucasSantana-Dev/Lucky/issues/136)
and auditable via `npm run lint:full --workspace=packages/backend`.
Backend lint now runs in strict mode across all backend routes and middleware.
Use `npm run lint:full --workspace=packages/backend` for explicit backend-only
verification in CI or local checks.

### Remote Deploy (No SSH)

Expand Down
5 changes: 2 additions & 3 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,8 @@
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"lint:base": "eslint . -c ../../eslint.config.js --ignore-pattern src/middleware/session.ts --ignore-pattern src/middleware/validate.ts --ignore-pattern src/routes/lastfm.ts --ignore-pattern src/routes/management.ts --ignore-pattern src/routes/managementAutoMessages.ts --ignore-pattern src/routes/managementEmbeds.ts --ignore-pattern src/routes/moderation.ts --ignore-pattern src/routes/music/playbackRoutes.ts --ignore-pattern src/routes/music/queueRoutes.ts --ignore-pattern src/routes/music/stateRoutes.ts --ignore-pattern src/routes/toggles.ts --ignore-pattern src/routes/twitch.ts",
"lint": "npm run lint:base",
"lint:fix": "npm run lint:base -- --fix",
"lint": "eslint . -c ../../eslint.config.js",
"lint:fix": "npm run lint -- --fix",
"lint:full": "eslint . -c ../../eslint.config.js"
},
"dependencies": {
Expand Down
58 changes: 32 additions & 26 deletions packages/backend/src/middleware/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import session from 'express-session'
import { RedisStore } from 'connect-redis'
import Redis from 'ioredis'
import sessionFileStoreFactory from 'session-file-store'
import { debugLog, errorLog } from '@lucky/shared/utils'
import type { Express } from 'express'

Expand Down Expand Up @@ -33,7 +34,6 @@
scanIterator: (options: RedisScanOptions) => AsyncIterable<string[]>
}

type RedisStoreClient = ConstructorParameters<typeof RedisStore>[0]['client']
type SessionMethodName = 'get' | 'set' | 'destroy' | 'touch'
type SessionCallback = (error?: unknown, data?: unknown) => void

Expand Down Expand Up @@ -144,7 +144,12 @@
callback: SessionCallback,
): void {
if (this.fallbackActive) {
this.invokeStoreMethod(this.fallbackStore, methodName, args, callback)
this.invokeStoreMethod(
this.fallbackStore,
methodName,
args,
callback,
)
return
}

Expand All @@ -171,7 +176,10 @@

get(
sid: string,
callback: (error?: unknown, sessionData?: session.SessionData | null) => void,
callback: (
error?: unknown,
sessionData?: session.SessionData | null,
) => void,
): void {
this.execute('get', [sid], callback as SessionCallback)
}
Expand All @@ -184,10 +192,7 @@
this.execute('set', [sid, sessionData], callback as SessionCallback)
}

destroy(
sid: string,
callback: (error?: unknown) => void = () => {},
): void {
destroy(sid: string, callback: (error?: unknown) => void = () => {}): void {
this.execute('destroy', [sid], callback as SessionCallback)
}

Expand Down Expand Up @@ -230,7 +235,7 @@

const storeClient = createConnectRedisClientAdapter(client)
return new RedisStore({
client: storeClient as unknown as RedisStoreClient,
client: storeClient,
prefix: 'lucky:sess:',
})
} catch (error) {
Expand All @@ -246,9 +251,7 @@
function createFileStore(sessionPath: string): session.Store | undefined {
try {
mkdirSync(sessionPath, { recursive: true })
// eslint-disable-next-line @typescript-eslint/no-require-imports
const FileStoreFactory = require('session-file-store')
const FileStore = FileStoreFactory(session)
const FileStore = sessionFileStoreFactory(session)
return new FileStore({
path: sessionPath,
ttl: 7 * 24 * 60 * 60,
Expand Down Expand Up @@ -288,32 +291,35 @@
: fallbackStore

const isMemoryFallback = fallbackStore.constructor.name === 'MemoryStore'
let storeType = 'file-based'
if (redisStore) {
storeType = isMemoryFallback
? 'Redis with in-memory fallback'
: 'Redis with file fallback'
} else if (isMemoryFallback) {
storeType = 'in-memory'
}

debugLog({ message: `Using ${storeType} session store` })

app.use(
session({
store,
secret: sessionSecret ?? 'default-secret-change-in-production',
secret: sessionSecret || 'fallback-secret-change-in-production',
name: 'sessionId',
resave: false,
saveUninitialized: false,
name: 'sessionId',
proxy: isProduction,
cookie: {
secure: isProduction,
httpOnly: true,
maxAge: 7 * 24 * 60 * 60 * 1000,
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/',
},
store,
rolling: true,
unset: 'destroy',
}),
)

debugLog({
message: 'Session middleware configured',
data: {
sessionPath,
store: redisStore
? `redis+fallback:${isMemoryFallback ? 'memory' : 'file'}`

Check warning on line 319 in packages/backend/src/middleware/session.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_NexusBot&issues=AZzZHvLMw1IpEXk994SU&open=AZzZHvLMw1IpEXk994SU&pullRequest=147
: isMemoryFallback
? 'memory'
: 'file',

Check warning on line 322 in packages/backend/src/middleware/session.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_NexusBot&issues=AZzZHvLMw1IpEXk994SV&open=AZzZHvLMw1IpEXk994SV&pullRequest=147
},
})
}
17 changes: 11 additions & 6 deletions packages/backend/src/middleware/validate.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,50 @@
import type { Request, Response, NextFunction } from 'express'
import { z } from 'zod'

export function validateBody<T extends z.ZodTypeAny>(schema: T) {
type Schema<TOutput> = z.ZodType<TOutput, z.ZodTypeDef, unknown>

export function validateBody<TOutput>(schema: Schema<TOutput>) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body)
const result = schema.safeParse(req.body as unknown)
if (!result.success) {
const errors = result.error.errors.map((e) => ({
field: e.path.join('.'),
message: e.message,
}))
return res.status(400).json({ error: 'Validation failed', errors })
}

req.body = result.data
next()
}
}

export function validateQuery<T extends z.ZodTypeAny>(schema: T) {
export function validateQuery<TOutput>(schema: Schema<TOutput>) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.query)
const result = schema.safeParse(req.query as unknown)
if (!result.success) {
const errors = result.error.errors.map((e) => ({
field: e.path.join('.'),
message: e.message,
}))
return res.status(400).json({ error: 'Validation failed', errors })
}

next()
}
}

export function validateParams<T extends z.ZodTypeAny>(schema: T) {
export function validateParams<TOutput>(schema: Schema<TOutput>) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.params)
const result = schema.safeParse(req.params as unknown)
if (!result.success) {
const errors = result.error.errors.map((e) => ({
field: e.path.join('.'),
message: e.message,
}))
return res.status(400).json({ error: 'Validation failed', errors })
}

next()
}
}
15 changes: 11 additions & 4 deletions packages/backend/src/routes/lastfm.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Express, Request, Response } from 'express'
import crypto from 'node:crypto'
import { z } from 'zod'
import { errorLog, debugLog } from '@lucky/shared/utils'
import { lastFmLinkService } from '@lucky/shared/services'
import {
Expand All @@ -15,6 +16,7 @@ import { getPrimaryFrontendUrl } from '../utils/frontendOrigin'

const LASTFM_STATE_COOKIE = 'lastfm_state'
const STATE_MAX_AGE_SEC = 600
const lastFmCallbackQuery = z.object({ token: z.string().min(1) })

function getLinkSecret(): string {
const secret =
Expand Down Expand Up @@ -174,14 +176,17 @@ export function setupLastFmRoutes(app: Express): void {
app.get('/api/lastfm/callback', async (req: Request, res: Response) => {
const frontendUrl = getFrontendUrl()
try {
const token = req.query.token
const stateFromCookie = req.cookies?.[LASTFM_STATE_COOKIE]
const cookies = req.cookies as Record<string, unknown> | undefined
const stateFromCookie = cookies?.[LASTFM_STATE_COOKIE]
const parsedQuery = lastFmCallbackQuery.safeParse(req.query)
res.clearCookie(LASTFM_STATE_COOKIE, { path: '/' })
if (!token || typeof token !== 'string') {

if (!parsedQuery.success) {
return res.redirect(
`${frontendUrl}/?error=lastfm_missing_token`,
)
}

if (!stateFromCookie || typeof stateFromCookie !== 'string') {
return res.redirect(
`${frontendUrl}/?error=lastfm_missing_state`,
Expand All @@ -194,7 +199,9 @@ export function setupLastFmRoutes(app: Express): void {
`${frontendUrl}/?error=lastfm_invalid_state`,
)
}
const session = await exchangeTokenForSession(token)
const session = await exchangeTokenForSession(
parsedQuery.data.token,
)
if (!session) {
return res.redirect(
`${frontendUrl}/?error=lastfm_exchange_failed`,
Expand Down
Loading