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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
16 changes: 16 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@
"@types/jest": "^30.0.0",
"@types/node": "^26.6.2",
"@types/supertest": "^7.2.1",
"better-sqlite3": "^12.11.1",
"eslint": "^10.11.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.5.6",
Expand Down
78 changes: 78 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -270,3 +270,81 @@ model OutboxEvent {
@@index([idempotencyKey])
@@index([eventType, status])
}

// ---------------------------------------------------------------------------
// V2 indexer: block cursor + reorg ancestry
// Supports src/v2/block-cursor (reorg detection by walking parentHash back to
// a common ancestor). Heights are BigInt because some EVM chains exceed
// Number.MAX_SAFE_INTEGER.
// ---------------------------------------------------------------------------

model BlockCursor {
id String @id @default(uuid())
chainId Int
// Logical indexer stream, e.g. "canonical-events"
source String

processedHeight BigInt @default(0)
processedHash String @default("")
safeHeight BigInt @default(0)
safeHash String @default("")
finalizedHeight BigInt @default(0)
finalizedHash String @default("")

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

ancestors BlockCursorAncestor[]

@@unique([chainId, source])
@@index([chainId])
}

model BlockCursorAncestor {
id String @id @default(uuid())
cursorId String
cursor BlockCursor @relation(fields: [cursorId], references: [id], onDelete: Cascade)

height BigInt
hash String
parentHash String
// observed | safe | finalized (matches ConfirmationLevel in block-cursor.types.ts)
confirmation String @default("observed")
// False once this height is known to belong to an orphaned branch
isCanonical Boolean @default(true)

createdAt DateTime @default(now())

@@unique([cursorId, height, hash])
@@index([cursorId, isCanonical])
}

// ---------------------------------------------------------------------------
// V2 indexer: canonical-identity dedup for raw EVM logs.
// A replay of the same (chainId, blockHash, txHash, logIndex) is rejected;
// the same log position under a different block hash is retained so reorgs
// stay diagnosable.
// ---------------------------------------------------------------------------

model ChainEvent {
id String @id @default(uuid())
chainId Int
contractAddress String
eventName String
// Decimal string, never a JS number/float.
blockNumber String
blockHash String
txHash String
logIndex Int
blockTimestamp DateTime?
// JSON-encoded RawChainLog.payload
payload String
// JSON-encoded RawChainLog.rawArgs
rawArgs String

ingestedAt DateTime @default(now())

@@unique([chainId, blockHash, txHash, logIndex])
@@index([chainId, txHash, logIndex])
@@index([eventName, blockNumber])
}
41 changes: 36 additions & 5 deletions src/admin/admin.controller.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,33 @@
import { Controller, Get, Post, Patch, Body, Param, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam, ApiQuery } from '@nestjs/swagger';
import {
Controller,
Get,
Post,
Patch,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
ApiQuery,
} from '@nestjs/swagger';
import { AdminService } from './admin.service';
import { AdminGuard } from './guards/admin.guard';
import { RolesGuard } from './guards/roles.guard';
import { Roles } from './decorators/roles.decorator';
import { CurrentAdmin } from './decorators/current-admin.decorator';
import { Admin, AdminRole } from './entities/admin.entity';
import { CreateAdminDto, UpdateAdminRoleDto, UpdateAdminStatusDto, AdminLoginDto } from './dto/admin.dto';
import {
CreateAdminDto,
UpdateAdminRoleDto,
UpdateAdminStatusDto,
AdminLoginDto,
} from './dto/admin.dto';

@ApiTags('admin')
@ApiBearerAuth()
Expand Down Expand Up @@ -37,7 +58,14 @@ export class AdminController {

@Get('auth/profile')
@UseGuards(AdminGuard, RolesGuard)
@Roles(AdminRole.SUPER_ADMIN, AdminRole.ADMINISTRATOR, AdminRole.MODERATOR, AdminRole.SECURITY_ANALYST, AdminRole.GOVERNANCE_OPERATOR, AdminRole.AUDITOR)
@Roles(
AdminRole.SUPER_ADMIN,
AdminRole.ADMINISTRATOR,
AdminRole.MODERATOR,
AdminRole.SECURITY_ANALYST,
AdminRole.GOVERNANCE_OPERATOR,
AdminRole.AUDITOR,
)
@ApiOperation({ summary: 'Get current admin profile' })
@ApiResponse({ status: 200, description: 'Admin profile' })
async getProfile(@CurrentAdmin() admin: Admin) {
Expand All @@ -56,7 +84,10 @@ export class AdminController {
@Roles(AdminRole.SUPER_ADMIN, AdminRole.ADMINISTRATOR)
@ApiOperation({ summary: 'Create a new admin (super_admin or admin only)' })
@ApiResponse({ status: 201, description: 'Admin created' })
async createAdmin(@Body() createAdminDto: CreateAdminDto, @CurrentAdmin() admin: Admin) {
async createAdmin(
@Body() createAdminDto: CreateAdminDto,
@CurrentAdmin() admin: Admin,
) {
return this.adminService.create(createAdminDto, admin);
}

Expand Down
8 changes: 7 additions & 1 deletion src/admin/admin.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,13 @@ import { QueueName } from '../jobs/jobs.types';

@Module({
imports: [
TypeOrmModule.forFeature([Admin, Incident, ModerationReport, AuditLog, Claim]),
TypeOrmModule.forFeature([
Admin,
Incident,
ModerationReport,
AuditLog,
Claim,
]),
FeatureFlagsModule,
JobsModule,
RedisModule,
Expand Down
62 changes: 50 additions & 12 deletions src/admin/admin.service.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import { Injectable, ConflictException, NotFoundException, Logger, ForbiddenException } from '@nestjs/common';
import {
Injectable,
ConflictException,
NotFoundException,
Logger,
ForbiddenException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Admin, AdminRole } from './entities/admin.entity';
import { CreateAdminDto, UpdateAdminRoleDto, UpdateAdminStatusDto } from './dto/admin.dto';
import {
CreateAdminDto,
UpdateAdminRoleDto,
UpdateAdminStatusDto,
} from './dto/admin.dto';

@Injectable()
export class AdminService {
Expand All @@ -13,7 +23,10 @@ export class AdminService {
private readonly adminRepo: Repository<Admin>,
) {}

async create(createAdminDto: CreateAdminDto, requestedBy: Admin): Promise<Admin> {
async create(
createAdminDto: CreateAdminDto,
requestedBy: Admin,
): Promise<Admin> {
if (!this.canManageAdmins(requestedBy.role)) {
throw new ForbiddenException('Insufficient permissions to create admins');
}
Expand All @@ -23,7 +36,9 @@ export class AdminService {
});

if (existing) {
throw new ConflictException('Admin with this wallet address already exists');
throw new ConflictException(
'Admin with this wallet address already exists',
);
}

const admin = this.adminRepo.create({
Expand All @@ -37,7 +52,10 @@ export class AdminService {
return saved;
}

async findAll(page = 1, limit = 20): Promise<{ data: Admin[]; total: number; page: number; limit: number }> {
async findAll(
page = 1,
limit = 20,
): Promise<{ data: Admin[]; total: number; page: number; limit: number }> {
const [data, total] = await this.adminRepo.findAndCount({
skip: (page - 1) * limit,
take: limit,
Expand All @@ -61,14 +79,23 @@ export class AdminService {
});
}

async updateRole(id: string, updateRoleDto: UpdateAdminRoleDto, requestedBy: Admin): Promise<Admin> {
async updateRole(
id: string,
updateRoleDto: UpdateAdminRoleDto,
requestedBy: Admin,
): Promise<Admin> {
if (!this.canManageAdmins(requestedBy.role)) {
throw new ForbiddenException('Insufficient permissions to update admin roles');
throw new ForbiddenException(
'Insufficient permissions to update admin roles',
);
}

const admin = await this.findById(id);

if (admin.role === AdminRole.SUPER_ADMIN && requestedBy.role !== AdminRole.SUPER_ADMIN) {
if (
admin.role === AdminRole.SUPER_ADMIN &&
requestedBy.role !== AdminRole.SUPER_ADMIN
) {
throw new ForbiddenException('Cannot modify a super admin');
}

Expand All @@ -78,20 +105,31 @@ export class AdminService {
return saved;
}

async updateStatus(id: string, updateStatusDto: UpdateAdminStatusDto, requestedBy: Admin): Promise<Admin> {
async updateStatus(
id: string,
updateStatusDto: UpdateAdminStatusDto,
requestedBy: Admin,
): Promise<Admin> {
if (!this.canManageAdmins(requestedBy.role)) {
throw new ForbiddenException('Insufficient permissions to update admin status');
throw new ForbiddenException(
'Insufficient permissions to update admin status',
);
}

const admin = await this.findById(id);

if (admin.role === AdminRole.SUPER_ADMIN && requestedBy.role !== AdminRole.SUPER_ADMIN) {
if (
admin.role === AdminRole.SUPER_ADMIN &&
requestedBy.role !== AdminRole.SUPER_ADMIN
) {
throw new ForbiddenException('Cannot modify a super admin');
}

admin.isActive = updateStatusDto.isActive;
const saved = await this.adminRepo.save(admin);
this.logger.log(`Admin ${id} status updated to ${updateStatusDto.isActive}`);
this.logger.log(
`Admin ${id} status updated to ${updateStatusDto.isActive}`,
);
return saved;
}

Expand Down
30 changes: 7 additions & 23 deletions src/admin/dashboard/dashboard.controller.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
import {
Controller,
Get,
Query,
UseGuards,
} from '@nestjs/common';
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import {
ApiBearerAuth,
ApiForbiddenResponse,
Expand Down Expand Up @@ -73,9 +68,7 @@ const AUDIT_ROLES = [
@Controller('admin/dashboard')
@UseGuards(AdminGuard, RolesGuard)
export class DashboardController {
constructor(
private readonly dashboardService: DashboardService,
) {}
constructor(private readonly dashboardService: DashboardService) {}

@Get('overview')
@Roles(...DASHBOARD_ROLES)
Expand Down Expand Up @@ -129,8 +122,7 @@ export class DashboardController {
@Roles(...DASHBOARD_ROLES)
@ApiOperation({
summary: 'Get infrastructure health',
description:
'Returns infrastructure health information and queue status.',
description: 'Returns infrastructure health information and queue status.',
})
@ApiResponse({
status: 200,
Expand Down Expand Up @@ -257,8 +249,7 @@ export class DashboardController {
@Roles(...DASHBOARD_ROLES)
@ApiOperation({
summary: 'Get background job metrics',
description:
'Returns background job queue and processing statistics.',
description: 'Returns background job queue and processing statistics.',
})
@ApiResponse({
status: 200,
Expand Down Expand Up @@ -326,8 +317,7 @@ export class DashboardController {
@Roles(...AUDIT_ROLES)
@ApiOperation({
summary: 'Get audit summary',
description:
'Returns audit activity for the requested number of days.',
description: 'Returns audit activity for the requested number of days.',
})
@ApiQuery({
name: 'days',
Expand All @@ -340,16 +330,10 @@ export class DashboardController {
status: 200,
description: 'Audit summary retrieved successfully.',
})
async getAuditSummary(
@Query('days') days?: string,
) {
async getAuditSummary(@Query('days') days?: string) {
const parsedDays = days === undefined ? 7 : Number(days);

if (
!Number.isInteger(parsedDays) ||
parsedDays < 1 ||
parsedDays > 365
) {
if (!Number.isInteger(parsedDays) || parsedDays < 1 || parsedDays > 365) {
throw new Error('days must be an integer between 1 and 365');
}

Expand Down
Loading