Skip to content

Commit 9d6215c

Browse files
authored
Merge pull request #1 from Jambox11/feature/backend-auth-blacklist-tickets-soft-delete-queue-concurrency
Backend auth, ticket assignment, soft-delete, queue concurrency
2 parents a25358b + 6483dc3 commit 9d6215c

19 files changed

Lines changed: 846 additions & 9 deletions

backend/docs/queue-concurrency.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# BullMQ Per-Queue Concurrency Configuration
2+
3+
Each queue worker can be configured with a specific concurrency level to control how many jobs are processed in parallel.
4+
5+
## Configuration
6+
7+
Set `QUEUE_CONCURRENCY_MAP` as a comma-separated list of `queue-name=N` pairs:
8+
9+
```
10+
QUEUE_CONCURRENCY_MAP=tx-submit=1,claim-events=5,claim-payouts=3
11+
```
12+
13+
## Default Values
14+
15+
| Queue | Default Concurrency | Rationale |
16+
|-------|---------------------|-----------|
17+
| `tx-submit` | 1 | Nonce-safe: serializes Stellar XDR submission to prevent nonce race conditions |
18+
| `claim-events` | 5 | Typical event indexing workload |
19+
| `claim-payouts` | 3 | Moderate payout processing capacity |
20+
21+
## Observability
22+
23+
Active worker count per queue is exposed as `bullmq_queue_active_workers` gauge in Prometheus:
24+
25+
```
26+
bullmq_queue_active_workers{queue="tx-submit"} 1
27+
bullmq_queue_active_workers{queue="claim-events"} 4
28+
bullmq_queue_active_workers{queue="claim-payouts"} 2
29+
```
30+
31+
## Tuning
32+
33+
- **Increase concurrency** for I/O-bound jobs (database, RPC calls) that can safely parallelize
34+
- **Decrease concurrency** for CPU-bound jobs or to reduce resource contention
35+
- **Monitor active worker gauge** in Grafana to detect saturation or underutilization
36+
- **tx-submit must remain at 1** to prevent Soroban account nonce conflicts across transactions

backend/prisma/schema.prisma

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,12 +284,15 @@ model SupportTicket {
284284
/// One-way hash of submitter IP for spam detection (no raw IP stored).
285285
ipHash String?
286286
status TicketStatus @default(OPEN)
287+
/// Staff member assigned to handle this ticket (null = unassigned).
288+
assignedTo String? @map("assigned_to")
287289
/// Set once when the first staff status update is recorded.
288290
firstRespondedAt DateTime? @map("first_responded_at")
289291
createdAt DateTime @default(now())
290292
updatedAt DateTime @updatedAt
291293
292294
@@index([status])
295+
@@index([assignedTo])
293296
@@index([createdAt])
294297
@@index([firstRespondedAt])
295298
@@map("support_tickets")

backend/src/admin/admin.controller.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ import { AdminStatsService } from './admin-stats.service';
4343
import { AdminAnalyticsService } from './admin-analytics.service';
4444
import { PrismaService } from '../prisma/prisma.service';
4545
import { SorobanService } from '../rpc/soroban.service';
46+
import { TokenBlacklistService } from '../auth/token-blacklist.service';
47+
import { SupportService } from '../support/support.service';
4648

4749
class BatchRegisterVotersDto {
4850
@IsArray()
@@ -76,6 +78,15 @@ class SetClaimSeverityDto {
7678
severity!: ClaimSeverity;
7779
}
7880

81+
class RevokeTokenDto {
82+
@IsString() jti!: string;
83+
@IsInt() expiresAt!: number;
84+
}
85+
86+
class AssignTicketDto {
87+
@IsOptional() @IsString() assignee?: string | null;
88+
}
89+
7990
type AdminRequest = Request & {
8091
user?: {
8192
walletAddress?: string;
@@ -111,6 +122,8 @@ export class AdminController {
111122
private readonly adminAnalyticsService: AdminAnalyticsService,
112123
private readonly prisma: PrismaService,
113124
private readonly sorobanService: SorobanService,
125+
private readonly tokenBlacklist: TokenBlacklistService,
126+
private readonly supportService: SupportService,
114127
) {}
115128

116129
// ── Governance: Voters ────────────────────────────────────────────
@@ -926,4 +939,68 @@ export class AdminController {
926939
});
927940
return result;
928941
}
942+
943+
// ── Auth: Token Management ─────────────────────────────────────────
944+
945+
/**
946+
* POST /admin/auth/revoke
947+
*
948+
* Revoke a JWT token immediately by adding to Redis blacklist.
949+
* Token remains blacklisted until its expiry time.
950+
*/
951+
@Post('auth/revoke')
952+
@HttpCode(HttpStatus.NO_CONTENT)
953+
@ApiOperation({ summary: 'Revoke a JWT token' })
954+
async revokeToken(@Body() dto: RevokeTokenDto, @Req() req: AdminRequest) {
955+
if (!dto.jti || dto.jti.length === 0) {
956+
throw new BadRequestException('jti must be a non-empty string');
957+
}
958+
if (!Number.isInteger(dto.expiresAt) || dto.expiresAt <= 0) {
959+
throw new BadRequestException('expiresAt must be a positive integer (Unix timestamp)');
960+
}
961+
962+
await this.tokenBlacklist.revokeToken(dto.jti, dto.expiresAt);
963+
964+
const actor = req.adminIdentity?.staffId || req.adminIdentity?.email || 'unknown';
965+
await this.auditService.write({
966+
actor,
967+
action: 'auth_token_revoke',
968+
payload: { jti: dto.jti },
969+
ipAddress: req.ip,
970+
});
971+
}
972+
973+
// ── Support: Ticket Management ─────────────────────────────────────
974+
975+
/**
976+
* GET /admin/support/tickets
977+
*
978+
* List all support tickets with optional filtering.
979+
*/
980+
@Get('support/tickets')
981+
@MinAdminRole('viewer')
982+
@ApiOperation({ summary: 'List support tickets' })
983+
async listSupportTickets(
984+
@Query('limit', new ParseIntPipe({ optional: true })) limit?: number,
985+
@Query('offset', new ParseIntPipe({ optional: true })) offset?: number,
986+
@Query('assignedTo') assignedTo?: string,
987+
) {
988+
return this.supportService.listTickets(limit || 50, offset || 0, assignedTo);
989+
}
990+
991+
/**
992+
* PATCH /admin/support/tickets/:id/assign
993+
*
994+
* Assign a support ticket to a staff member or unassign it.
995+
*/
996+
@Patch('support/tickets/:id/assign')
997+
@ApiOperation({ summary: 'Assign support ticket to staff member' })
998+
async assignSupportTicket(
999+
@Param('id') ticketId: string,
1000+
@Body() dto: AssignTicketDto,
1001+
@Req() req: AdminRequest,
1002+
) {
1003+
const actor = req.adminIdentity?.staffId || req.adminIdentity?.email || 'unknown';
1004+
return this.supportService.assignTicket(ticketId, dto.assignee ?? null, actor, req.ip);
1005+
}
9291006
}

backend/src/auth/auth.module.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { NonceService } from './nonce.service';
88
import { RefreshTokenService } from './refresh-token.service';
99
import { AuthController } from './auth.controller';
1010
import { AuthIdentityService } from './auth-identity.service';
11+
import { TokenBlacklistService } from './token-blacklist.service';
1112
import { CacheModule } from '../cache/cache.module';
1213

1314
@Module({
@@ -24,7 +25,7 @@ import { CacheModule } from '../cache/cache.module';
2425
}),
2526
],
2627
controllers: [AuthController],
27-
providers: [JwtStrategy, WalletAuthService, NonceService, RefreshTokenService, AuthIdentityService],
28-
exports: [PassportModule, JwtModule, AuthIdentityService],
28+
providers: [JwtStrategy, WalletAuthService, NonceService, RefreshTokenService, AuthIdentityService, TokenBlacklistService],
29+
exports: [PassportModule, JwtModule, AuthIdentityService, TokenBlacklistService],
2930
})
3031
export class AuthModule {}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { UnauthorizedException } from '@nestjs/common';
2+
import { ConfigService } from '@nestjs/config';
3+
import { JwtStrategy, JwtPayload } from './jwt.strategy';
4+
import { TokenBlacklistService } from '../token-blacklist.service';
5+
6+
describe('JwtStrategy', () => {
7+
let strategy: JwtStrategy;
8+
let mockConfigService: Partial<ConfigService>;
9+
let mockBlacklistService: Partial<TokenBlacklistService>;
10+
11+
beforeEach(() => {
12+
mockConfigService = {
13+
get: jest.fn((key: string) => {
14+
if (key === 'JWT_SECRET') return 'test-secret';
15+
return undefined;
16+
}),
17+
};
18+
mockBlacklistService = {
19+
isBlacklisted: jest.fn().mockResolvedValue(false),
20+
};
21+
strategy = new JwtStrategy(
22+
mockConfigService as ConfigService,
23+
mockBlacklistService as TokenBlacklistService,
24+
);
25+
});
26+
27+
it('validate accepts valid payload without jti', async () => {
28+
const payload: JwtPayload = {
29+
sub: 'user123',
30+
walletAddress: 'GXXX...',
31+
};
32+
33+
const result = await strategy.validate(payload);
34+
35+
expect(result.walletAddress).toBe('GXXX...');
36+
expect(mockBlacklistService.isBlacklisted).not.toHaveBeenCalled();
37+
});
38+
39+
it('validate rejects payload without walletAddress', async () => {
40+
const payload = { sub: 'user123' } as JwtPayload;
41+
42+
await expect(strategy.validate(payload)).rejects.toThrow(UnauthorizedException);
43+
});
44+
45+
it('validate checks blacklist when jti is present', async () => {
46+
const payload: JwtPayload = {
47+
sub: 'user123',
48+
walletAddress: 'GXXX...',
49+
jti: 'token-id-123',
50+
};
51+
52+
await strategy.validate(payload);
53+
54+
expect(mockBlacklistService.isBlacklisted).toHaveBeenCalledWith('token-id-123');
55+
});
56+
57+
it('validate rejects blacklisted tokens', async () => {
58+
(mockBlacklistService.isBlacklisted as jest.Mock).mockResolvedValueOnce(true);
59+
const payload: JwtPayload = {
60+
sub: 'user123',
61+
walletAddress: 'GXXX...',
62+
jti: 'blacklisted-token-id',
63+
};
64+
65+
await expect(strategy.validate(payload)).rejects.toThrow(
66+
new UnauthorizedException('Token has been revoked'),
67+
);
68+
});
69+
70+
it('validate accepts non-blacklisted tokens with jti', async () => {
71+
(mockBlacklistService.isBlacklisted as jest.Mock).mockResolvedValueOnce(false);
72+
const payload: JwtPayload = {
73+
sub: 'user123',
74+
walletAddress: 'GXXX...',
75+
jti: 'valid-token-id',
76+
};
77+
78+
const result = await strategy.validate(payload);
79+
80+
expect(result.walletAddress).toBe('GXXX...');
81+
});
82+
83+
it('validate passes through iat and exp timestamps', async () => {
84+
const payload: JwtPayload = {
85+
sub: 'user123',
86+
walletAddress: 'GXXX...',
87+
iat: 1234567890,
88+
exp: 1234567890 + 3600,
89+
};
90+
91+
const result = await strategy.validate(payload);
92+
93+
expect(result.walletAddress).toBe('GXXX...');
94+
});
95+
});

backend/src/auth/strategies/jwt.strategy.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,22 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
22
import { PassportStrategy } from '@nestjs/passport';
33
import { ExtractJwt, Strategy } from 'passport-jwt';
44
import { ConfigService } from '@nestjs/config';
5+
import { TokenBlacklistService } from '../token-blacklist.service';
56

67
export interface JwtPayload {
78
sub: string; // Wallet address
89
walletAddress: string;
10+
jti?: string; // JWT ID for revocation
911
iat?: number;
1012
exp?: number;
1113
}
1214

1315
@Injectable()
1416
export class JwtStrategy extends PassportStrategy(Strategy) {
15-
constructor(private readonly configService: ConfigService) {
17+
constructor(
18+
private readonly configService: ConfigService,
19+
private readonly blacklist: TokenBlacklistService,
20+
) {
1621
const primary = configService.get<string>('JWT_SECRET') ?? ''
1722

1823
super({
@@ -26,6 +31,14 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
2631
if (!payload.walletAddress) {
2732
throw new UnauthorizedException('Invalid token payload');
2833
}
34+
35+
if (payload.jti) {
36+
const isBlacklisted = await this.blacklist.isBlacklisted(payload.jti);
37+
if (isBlacklisted) {
38+
throw new UnauthorizedException('Token has been revoked');
39+
}
40+
}
41+
2942
return { walletAddress: payload.walletAddress };
3043
}
3144
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { TokenBlacklistService } from './token-blacklist.service';
2+
import { RedisService } from '../cache/redis.service';
3+
4+
describe('TokenBlacklistService', () => {
5+
let service: TokenBlacklistService;
6+
let redisService: Partial<RedisService>;
7+
let mockRedisClient: Record<string, jest.Mock>;
8+
9+
beforeEach(() => {
10+
mockRedisClient = {
11+
setex: jest.fn().mockResolvedValue('OK'),
12+
exists: jest.fn().mockResolvedValue(0),
13+
};
14+
redisService = {
15+
client: mockRedisClient as any,
16+
};
17+
service = new TokenBlacklistService(redisService as RedisService);
18+
});
19+
20+
it('blacklistToken sets key with TTL in Redis', async () => {
21+
await service.blacklistToken('test-jti', 3600);
22+
23+
expect(mockRedisClient.setex).toHaveBeenCalledWith('token:blacklist:test-jti', 3600, '1');
24+
});
25+
26+
it('blacklistToken ignores tokens with 0 or negative TTL', async () => {
27+
await service.blacklistToken('expired-jti', 0);
28+
await service.blacklistToken('very-expired-jti', -100);
29+
30+
expect(mockRedisClient.setex).not.toHaveBeenCalled();
31+
});
32+
33+
it('isBlacklisted returns true when token exists in Redis', async () => {
34+
mockRedisClient.exists.mockResolvedValueOnce(1);
35+
36+
const result = await service.isBlacklisted('blacklisted-jti');
37+
38+
expect(result).toBe(true);
39+
expect(mockRedisClient.exists).toHaveBeenCalledWith('token:blacklist:blacklisted-jti');
40+
});
41+
42+
it('isBlacklisted returns false when token not in Redis', async () => {
43+
mockRedisClient.exists.mockResolvedValueOnce(0);
44+
45+
const result = await service.isBlacklisted('not-blacklisted-jti');
46+
47+
expect(result).toBe(false);
48+
});
49+
50+
it('revokeToken calculates TTL from expiry timestamp', async () => {
51+
const nowSeconds = Math.floor(Date.now() / 1000);
52+
const expiresAt = nowSeconds + 7200; // expires in 2 hours
53+
const expectedTtl = 7200;
54+
55+
await service.revokeToken('jti-to-revoke', expiresAt);
56+
57+
const calls = mockRedisClient.setex.mock.calls[0];
58+
expect(calls[0]).toBe('token:blacklist:jti-to-revoke');
59+
expect(calls[1]).toBe(expectedTtl);
60+
expect(calls[2]).toBe('1');
61+
});
62+
63+
it('revokeToken uses 0 TTL for already-expired tokens', async () => {
64+
const nowSeconds = Math.floor(Date.now() / 1000);
65+
const expiresAt = nowSeconds - 100; // expired 100 seconds ago
66+
67+
await service.revokeToken('already-expired-jti', expiresAt);
68+
69+
expect(mockRedisClient.setex).not.toHaveBeenCalled();
70+
});
71+
72+
it('blacklistToken uses correct key prefix', async () => {
73+
await service.blacklistToken('my-jti', 100);
74+
75+
const key = mockRedisClient.setex.mock.calls[0][0];
76+
expect(key).toMatch(/^token:blacklist:/);
77+
expect(key).toContain('my-jti');
78+
});
79+
});

0 commit comments

Comments
 (0)