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
91 changes: 78 additions & 13 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,62 @@ concurrency:
# Onchain jobs (contracts)
# ─────────────────────────────────────────────────────────────────────
jobs:
# Changed-path matrix: only run the jobs relevant to the files touched
# in a PR/push, while preserving a full run whenever shared configuration
# (lockfile, workflows, release config, root manifests) changes. This
# speeds up validation without losing coverage (#321).
changes:
name: Detect changed paths
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
onchain: ${{ steps.filter.outputs.onchain }}
shared: ${{ steps.filter.outputs.shared }}
run-all: ${{ steps.filter.outputs.run_all }}
steps:
- uses: actions/checkout@v4

- uses: dorny/paths-filter@v3
id: filter
with:
base: ${{ github.event.pull_request.base.sha || 'main' }}
filters: |
backend:
- 'backend/**'
frontend:
- 'frontend/**'
onchain:
- 'onchain/**'
# Shared configuration affects every project: any of these alone
# is enough to force a full (all jobs) validation run.
shared:
- 'package.json'
- 'package-lock.json'
- 'npm-workspaces.yaml'
- '**/package.json'
- '**/package-lock.json'
- '.github/workflows/**.yml'
- '.github/workflows/**.yaml'
run_all:
- 'package.json'
- 'package-lock.json'
- '**/package.json'
- '**/package-lock.json'
- '.github/workflows/release.yml'
- 'onchain/Scarb.lock'
- 'onchain/Scarb.toml'
- 'onchain/Cargo.lock'
- 'onchain/Cargo.toml'

# Helper: resolves the effective "should this job run?" boolean by OR-ing
# the project-specific filter with the shared/run-all signal.
onchain-build:
name: Build contracts
needs: changes
if: ${{ needs.changes.outputs.onchain == 'true' || needs.changes.outputs.run-all == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
Expand Down Expand Up @@ -76,6 +130,8 @@ jobs:

onchain-test:
name: Test contracts
needs: changes
if: ${{ needs.changes.outputs.onchain == 'true' || needs.changes.outputs.run-all == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
Expand Down Expand Up @@ -139,6 +195,8 @@ jobs:
# ─────────────────────────────────────────────────────────────────────
backend-lint:
name: Backend lint
needs: changes
if: ${{ needs.changes.outputs.backend == 'true' || needs.changes.outputs.shared == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
Expand Down Expand Up @@ -174,6 +232,8 @@ jobs:

backend-test:
name: Backend tests
needs: changes
if: ${{ needs.changes.outputs.backend == 'true' || needs.changes.outputs.shared == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
Expand Down Expand Up @@ -202,6 +262,8 @@ jobs:
# ─────────────────────────────────────────────────────────────────────
frontend-lint:
name: Frontend lint
needs: changes
if: ${{ needs.changes.outputs.frontend == 'true' || needs.changes.outputs.shared == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
Expand All @@ -219,25 +281,25 @@ jobs:

- name: Lint
working-directory: frontend
# Now that @types/node is in devDependencies (added in commit 2a7ce2a),
# `next lint` should pass. Kept non-blocking while we verify.
continue-on-error: true
# Required gate (#311): `next lint` passes cleanly, so a lint failure
# now blocks the PR.
run: npm run lint

- name: npm audit
working-directory: frontend
# The high threshold is enforced (issue #344). All auto-fixable
# findings have been resolved; the remaining high-severity
# advisories are Next.js framework issues (next <16.3.3, plus the
# glob/postcss pinned by @next/eslint-plugin-next) that only a
# Next.js major upgrade can clear. Tracked as residual risk in
# SECURITY.md; advisory until that upgrade lands.
# Advisory (#311/#344): the remaining high-severity advisories are
# Next.js framework issues (next, next-auth -> nodemailer) that only
# a Next.js major upgrade can clear (tracked in SECURITY.md). The
# dependency-review gate in security.yml already fail-closes on any
# *new* advisory, so npm audit stays advisory here.
continue-on-error: true
run: npm audit --audit-level=high


frontend-build:
name: Frontend build
needs: changes
if: ${{ needs.changes.outputs.frontend == 'true' || needs.changes.outputs.shared == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
Expand All @@ -255,15 +317,16 @@ jobs:

- name: Build
working-directory: frontend
# Advisory only — pending fix-up of pre-existing frontend build errors
# in the codebase (separate PR).
continue-on-error: true
# Required gate (#311): the pre-existing build errors (missing
# `onClaim` prop, invalid tsconfig `ignoreDeprecations`) are fixed,
# so a build failure now blocks the PR.
run: npm run build

frontend-smoke-test:
name: Frontend production smoke test
needs: [changes, frontend-build]
if: ${{ needs.changes.outputs.frontend == 'true' || needs.changes.outputs.shared == 'true' }}
runs-on: ubuntu-latest
needs: frontend-build
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7

Expand Down Expand Up @@ -305,6 +368,8 @@ jobs:

frontend-test:
name: Frontend tests
needs: changes
if: ${{ needs.changes.outputs.frontend == 'true' || needs.changes.outputs.shared == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
Expand Down
1 change: 1 addition & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ FRONTEND_URL=http://localhost:3000

# Auth
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=30d

# Database extras
DATABASE_SYNC=false
Expand Down
15 changes: 13 additions & 2 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import { TimeTrial } from './time-trial/time-trial.entity';
import { Puzzle } from './puzzle/puzzle.entity';
import { Category } from './puzzle-category/entities/category.entity';
import { Report } from './report/entities/report.entity';
import { Wallet } from './wallet/entities/wallet.entity';
import { ConsumedWalletNonce } from './wallet/entities/consumed-nonce.entity';
import { TokenHistory } from './user-token-history/entities/token-history.entity';
import { AuditLog } from './audit-log/entities/audit-log.entity';
import { Admin } from './admin/admin.entity';
import { PuzzleReview } from './puzzle-review/puzzle-review/entities/puzzle-review.entity';
Expand Down Expand Up @@ -76,6 +79,7 @@ import { PuzzleReviewModule } from './puzzle-review/puzzle-review/puzzle-review.
import { UserReportCardModule } from './user-report-card/user-report-card.module';
import { HealthModule } from './health/health.module';
import { MaintenanceModeModule } from './maintenance-mode/maintenance-mode.module';
import { WalletModule } from './wallet/wallet.module';
import { GracefulShutdownService } from './graceful-shutdown.service';

@Module({
Expand All @@ -92,6 +96,7 @@ import { GracefulShutdownService } from './graceful-shutdown.service';
PORT: Joi.number().port().default(3001),
JWT_SECRET: Joi.string().required(),
JWT_EXPIRES_IN: Joi.string().default('15m'),
JWT_REFRESH_EXPIRES_IN: Joi.string().default('30d'),
FRONTEND_URL: Joi.string().uri().default('http://localhost:3000'),
DATABASE_HOST: Joi.string().required(),
DATABASE_PORT: Joi.number().port().default(5432),
Expand Down Expand Up @@ -146,14 +151,19 @@ import { GracefulShutdownService } from './graceful-shutdown.service';
Puzzle,
Category,
Report,
Wallet,
ConsumedWalletNonce,
TokenHistory,
AuditLog,
Admin,
PuzzleReview,
ReviewModeration,
DraftPuzzle,
],
synchronize: configService.get('database.synchronize'),
autoLoadEntities: configService.get('database.autoload'),
migrations: [join(__dirname, '**', 'migrations', '*.{ts,js}')],
synchronize: configService.get('database.synchronize') === true,
autoLoadEntities: configService.get('database.autoload') === true,
migrationsRun: configService.get('database.migrationsRun') === true,
}),
}),
AchievementModule,
Expand Down Expand Up @@ -205,6 +215,7 @@ import { GracefulShutdownService } from './graceful-shutdown.service';
UserRankingModule,
UserReactionModule,
UserReportCardModule,
MaintenanceModeModule,
UserSettingsModule,
UserTokenHistoryModule,
WalletModule,
Expand Down
2 changes: 2 additions & 0 deletions backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ import { AuthController } from './controllers/auth.controller';
import { AuthService } from './services/auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { UserTokenHistoryModule } from '../user-token-history/user-token-history.module';
import * as Joi from 'joi';

@Module({
imports: [
TypeOrmModule.forFeature([User]),
UserTokenHistoryModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
ConfigModule.forRoot({
isGlobal: true,
Expand Down
92 changes: 81 additions & 11 deletions backend/src/auth/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,29 @@
import { Controller, Post, Get, UseGuards, Request, HttpStatus, HttpCode, Body } from "@nestjs/common"
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from "@nestjs/swagger"
import { AuthService } from "../services/auth.service"
import { Auth } from "../decorators/auth-decorator"
import { AuthType } from "../enums/auth-type.enum"
import { AuthResponseDto } from "../dto/auth-response.dto"
import { GenericAuthMessageDto } from "../dto/generic-auth-message.dto"
import { RegisterDto } from "../dto/register.dto"
import { LoginDto } from "../dto/login.dto"
import { JwtAuthGuard } from "../guards/jwt-auth.guard"
import { User } from "../entities/user.entity"
import {
Controller,
Post,
Get,
UseGuards,
Request,
HttpStatus,
HttpCode,
Body,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { AuthService } from '../services/auth.service';
import { Auth } from '../decorators/auth-decorator';
import { AuthType } from '../enums/auth-type.enum';
import { AuthResponseDto } from '../dto/auth-response.dto';
import { RegisterDto } from '../dto/register.dto';
import { LoginDto } from '../dto/login.dto';
import { RefreshTokenDto } from '../dto/refresh-token.dto';
import { LogoutDto } from '../dto/logout.dto';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { User } from '../entities/user.entity';

@ApiTags('Authentication')
@Controller('auth')
Expand Down Expand Up @@ -119,4 +134,59 @@ export class AuthController {
},
};
}

@Post('refresh')
@Auth(AuthType.None)
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Refresh access token',
description:
'Exchange a valid refresh token for a fresh access/refresh token pair (rotation)',
})
@ApiResponse({
status: 200,
description: 'New token pair issued',
type: AuthResponseDto,
})
@ApiResponse({
status: 401,
description: 'Invalid, revoked or expired refresh token',
})
async refresh(@Body() refreshTokenDto: RefreshTokenDto) {
return this.authService.refreshToken(refreshTokenDto.refreshToken);
}

@Post('logout')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Logout',
description:
'Revoke the current access token (and optionally a refresh token) server-side',
})
@ApiResponse({
status: 200,
description: 'Logged out successfully',
})
@ApiResponse({
status: 401,
description: 'Invalid or expired token',
})
async logout(
@Request() req: { user: User; headers: any },
@Body() logoutDto: LogoutDto,
) {
const authHeader = req.headers?.authorization;
const accessToken =
typeof authHeader === 'string' && authHeader.startsWith('Bearer ')
? authHeader.slice(7)
: undefined;

return this.authService.logout(
req.user.id,
accessToken,
logoutDto.refreshToken,
);
}
}
14 changes: 13 additions & 1 deletion backend/src/auth/dto/auth-response.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,30 @@ export class AuthResponseDto {
})
accessToken: string;

@ApiProperty({
description: 'JWT refresh token used to obtain a new access token',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
})
refreshToken: string;

@ApiProperty({
description: 'Token type',
example: 'Bearer',
})
tokenType: string;

@ApiProperty({
description: 'Token expiration time in seconds',
description: 'Access token expiration time in seconds',
example: 900,
})
expiresIn: number;

@ApiProperty({
description: 'Refresh token expiration time in seconds',
example: 2592000,
})
refreshExpiresIn?: number;

@ApiProperty({
description: 'User information',
type: () => UserDto,
Expand Down
13 changes: 13 additions & 0 deletions backend/src/auth/dto/logout.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';

export class LogoutDto {
@ApiPropertyOptional({
description:
'Refresh token to revoke alongside the current access token. If omitted only the access token is revoked.',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
})
@IsOptional()
@IsString()
refreshToken?: string;
}
Loading